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
from frappe.translate import get_all_translations
from frappe.utils import cstr
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
@frappe.whitelist(allow_guest=True)
@ -44,4 +45,13 @@ def get_user_signature():
content = ""
if (cstr(_signature) or signature):
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
data = []
_list = get_controller(doctype)
default_rows = []
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 columns or rows:
@ -278,6 +279,7 @@ def get_data(
rows = frappe.parse_json(list_view_settings.rows)
is_default = False
elif not custom_view or is_default and hasattr(_list, "default_list_data"):
rows = default_rows
columns = _list.default_list_data().get("columns")
# check if rows has all keys from columns if not add them
@ -302,6 +304,9 @@ def get_data(
) or []
if view_type == "kanban":
if not rows:
rows = default_rows
if not kanban_columns and column_field:
field_meta = frappe.get_meta(doctype).get_field(column_field)
if field_meta.fieldtype == "Link":

View File

@ -149,6 +149,7 @@ class CRMLead(Document):
"organization_name": self.organization,
"website": self.website,
"territory": self.territory,
"industry": self.industry,
"annual_revenue": self.annual_revenue,
}
)

View File

@ -53,11 +53,11 @@ def create(view):
def update(view):
view = frappe._dict(view)
filters = parse_json(view.filters) or {}
columns = parse_json(view.columns) or []
rows = parse_json(view.rows) or []
kanban_columns = parse_json(view.kanban_columns) or []
kanban_fields = parse_json(view.kanban_fields) or []
filters = parse_json(view.filters or {})
columns = parse_json(view.columns or [])
rows = parse_json(view.rows or [])
kanban_columns = parse_json(view.kanban_columns or [])
kanban_fields = parse_json(view.kanban_fields or [])
default_rows = sync_default_rows(view.doctype)
rows = rows + default_rows if default_rows else rows
@ -92,6 +92,8 @@ def public(name, value):
frappe.throw("Not permitted", frappe.PermissionError)
doc = frappe.get_doc("CRM View Settings", name)
if doc.pinned:
doc.pinned = False
doc.public = value
doc.user = "" if value else frappe.session.user
doc.save()

View File

@ -4,11 +4,23 @@ app_publisher = "Frappe Technologies Pvt. Ltd."
app_description = "Kick-ass Open Source CRM"
app_email = "shariq@frappe.io"
app_license = "AGPLv3"
app_icon_url = ""
app_icon_url = "/assets/crm/manifest/apple-icon-180.png"
app_icon_title = "CRM"
app_icon_route = "/crm"
# 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>
# ------------------

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/integrations": "^10.3.0",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.63",
"frappe-ui": "^0.1.66",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -1,6 +1,5 @@
<template>
<router-view v-if="$route.name == 'Login'" />
<Layout v-else-if="session().isLoggedIn">
<Layout v-if="session().isLoggedIn">
<router-view />
</Layout>
<Dialogs />

View File

@ -437,6 +437,7 @@ import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import { whatsappEnabled } from '@/composables/settings'
import { capture } from '@/telemetry'
import { Button, Tooltip, createResource } from 'frappe-ui'
import { useElementVisibility } from '@vueuse/core'
import {
@ -552,6 +553,7 @@ onMounted(() => {
function sendTemplate(template) {
showWhatsappTemplates.value = false
capture('send_whatsapp_template', { doctype: props.doctype })
createResource({
url: 'crm.api.whatsapp.send_whatsapp_template',
params: {

View File

@ -170,9 +170,9 @@ import DoubleCheckIcon from '@/components/Icons/DoubleCheckIcon.vue'
import DocumentIcon from '@/components/Icons/DocumentIcon.vue'
import ReactIcon from '@/components/Icons/ReactIcon.vue'
import { dateFormat } from '@/utils'
import { capture } from '@/telemetry'
import { Tooltip, Dropdown, createResource } from 'frappe-ui'
import { ref } from 'vue'
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
const props = defineProps({
messages: Array,
@ -219,6 +219,7 @@ function reactOnMessage(name, emoji) {
},
auto: true,
onSuccess() {
capture('whatsapp_react_on_message')
list.value.reload()
},
})

View File

@ -39,6 +39,7 @@
() => {
content += emoji
$refs.textarea.$el.focus()
capture('whatsapp_emoji_added')
}
"
>
@ -65,8 +66,8 @@
<script setup>
import IconPicker from '@/components/IconPicker.vue'
import SmileIcon from '@/components/Icons/SmileIcon.vue'
import { capture } from '@/telemetry'
import { createResource, Textarea, FileUploader, Dropdown } from 'frappe-ui'
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
import { ref, nextTick, watch } from 'vue'
const props = defineProps({
@ -92,6 +93,7 @@ function uploadFile(file) {
whatsapp.value.attach = file.file_url
whatsapp.value.content_type = fileType.value
sendWhatsAppMessage()
capture('whatsapp_upload_file')
}
function sendTextMessage(event) {
@ -99,6 +101,7 @@ function sendTextMessage(event) {
sendWhatsAppMessage()
textarea.value.$el.blur()
content.value = ''
capture('whatsapp_send_message')
}
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 { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts'
import { capture } from '@/telemetry'
import { Avatar, call } from 'frappe-ui'
import { onMounted, ref, watch } from 'vue'
@ -403,6 +404,8 @@ async function makeOutgoingCall(number) {
showCallPopup.value = true
callStatus.value = 'initiating'
capture('make_outgoing_call')
_call.on('messageReceived', (message) => {
let info = message.content
callStatus.value = info.CallStatus

View File

@ -92,6 +92,7 @@ import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue'
import { usersStore } from '@/stores/users'
import { TextEditorBubbleMenu, TextEditor, FileUploader } from 'frappe-ui'
import { capture } from '@/telemetry'
import { EditorContent } from '@tiptap/vue-3'
import { ref, computed, defineModel } from 'vue'
@ -139,6 +140,7 @@ function appendEmoji() {
editor.value.commands.insertContent(emoji.value)
editor.value.commands.focus()
emoji.value = ''
capture('emoji_inserted_in_comment', { emoji: emoji.value })
}
function removeAttachment(attachment) {

View File

@ -88,6 +88,7 @@ import EmailEditor from '@/components/EmailEditor.vue'
import CommentBox from '@/components/CommentBox.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import { capture } from '@/telemetry'
import { usersStore } from '@/stores/users'
import { useStorage } from '@vueuse/core'
import { call, createResource } from 'frappe-ui'
@ -176,6 +177,10 @@ async function sendMail() {
let subject = newEmailEditor.value.subject
let cc = newEmailEditor.value.ccEmails || []
let bcc = newEmailEditor.value.bccEmails || []
if (attachments.value.length) {
capture('email_attachments_added')
}
await call('frappe.core.doctype.communication.email.make', {
recipients: recipients.join(', '),
attachments: attachments.value.map((x) => x.name),
@ -200,6 +205,7 @@ async function sendComment() {
comment_by: getUser()?.full_name || undefined,
})
if (comment && attachments.value.length) {
capture('comment_attachments_added')
await call('crm.api.comment.add_attachments', {
name: comment.name,
attachments: attachments.value.map((x) => x.name),
@ -214,6 +220,7 @@ async function submitEmail() {
newEmail.value = ''
reload.value = true
emit('scroll')
capture('email_sent', { doctype: props.doctype })
}
async function submitComment() {
@ -223,6 +230,7 @@ async function submitComment() {
newComment.value = ''
reload.value = true
emit('scroll')
capture('comment_sent', { doctype: props.doctype })
}
function toggleEmailBox() {

View File

@ -175,6 +175,7 @@ import AttachmentItem from '@/components/AttachmentItem.vue'
import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
import { capture } from '@/telemetry'
import { validateEmail } from '@/utils'
import Paragraph from '@tiptap/extension-paragraph'
import { EditorContent } from '@tiptap/vue-3'
@ -273,12 +274,14 @@ async function applyEmailTemplate(template) {
editor.value.commands.setContent(data.message)
}
showEmailTemplateSelectorModal.value = false
capture('email_template_applied', { doctype: props.doctype })
}
function appendEmoji() {
editor.value.commands.insertContent(emoji.value)
editor.value.commands.focus()
emoji.value = ''
capture('emoji_inserted_in_email', { emoji: emoji.value })
}
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 { setupListActions, createToast } from '@/utils'
import { globalStore } from '@/stores/global'
import { capture } from '@/telemetry'
import { call } from 'frappe-ui'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
@ -56,6 +57,40 @@ function editValues(selections, 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) {
$dialog({
title: __('Delete'),
@ -70,6 +105,7 @@ function deleteValues(selections, unselectAll) {
variant: 'solid',
theme: 'red',
onClick: (close) => {
capture('bulk_delete')
call('frappe.desk.reportview.delete_items', {
items: JSON.stringify(Array.from(selections)),
doctype: props.doctype,
@ -112,6 +148,7 @@ function clearAssignemnts(selections, unselectAll) {
variant: 'solid',
theme: 'red',
onClick: (close) => {
capture('bulk_clear_assignment')
call('frappe.desk.form.assign_to.remove_multiple', {
doctype: props.doctype,
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) => {
actions.push({
label: __(action.label),

View File

@ -85,6 +85,11 @@
<div>{{ item.timeAgo }}</div>
</Tooltip>
</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'">
<FormControl
type="checkbox"
@ -233,7 +238,7 @@ const listBulkActionsRef = ref(null)
defineExpose({
customListActions: computed(
() => listBulkActionsRef.value?.customListActions
() => listBulkActionsRef.value?.customListActions,
),
})
</script>

View File

@ -9,7 +9,7 @@
label: __('Cancel'),
variant: 'subtle',
onClick: () => {
assignees = oldAssignees
assignees = [...oldAssignees]
show = false
},
},
@ -20,6 +20,11 @@
},
],
}"
@close="
() => {
assignees = [...oldAssignees]
}
"
>
<template #body-content>
<Link
@ -75,9 +80,9 @@
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { capture } from '@/telemetry'
import { Tooltip, call } from 'frappe-ui'
import { ref, computed } from 'vue'
import { watchOnce } from '@vueuse/core'
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
doc: {
@ -106,7 +111,7 @@ const { getUser } = usersStore()
const removeValue = (value) => {
assignees.value = assignees.value.filter(
(assignee) => assignee.name !== value
(assignee) => assignee.name !== value,
)
}
@ -135,13 +140,13 @@ function updateAssignees() {
}
const removedAssignees = oldAssignees.value
.filter(
(assignee) => !assignees.value.find((a) => a.name === assignee.name)
(assignee) => !assignees.value.find((a) => a.name === assignee.name),
)
.map((assignee) => assignee.name)
const addedAssignees = assignees.value
.filter(
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name)
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name),
)
.map((assignee) => assignee.name)
@ -157,6 +162,7 @@ function updateAssignees() {
if (addedAssignees.length) {
if (props.docs.size) {
capture('bulk_assign_to', { doctype: props.doctype })
call('frappe.desk.form.assign_to.add_multiple', {
doctype: props.doctype,
name: JSON.stringify(Array.from(props.docs)),
@ -167,6 +173,7 @@ function updateAssignees() {
emit('reload')
})
} else {
capture('assign_to', { doctype: props.doctype })
call('frappe.desk.form.assign_to.add', {
doctype: props.doctype,
name: props.doc.name,
@ -177,7 +184,7 @@ function updateAssignees() {
show.value = false
}
watchOnce(assignees, (value) => {
oldAssignees.value = [...value]
onMounted(() => {
oldAssignees.value = [...assignees.value]
})
</script>

View File

@ -92,6 +92,7 @@ import CertificateIcon from '@/components/Icons/CertificateIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import { usersStore } from '@/stores/users'
import { capture } from '@/telemetry'
import { call, createResource } from 'frappe-ui'
import { ref, nextTick, watch, computed } from 'vue'
import { createToast } from '@/utils'
@ -160,7 +161,10 @@ async function callInsertDoc() {
..._contact.value,
},
})
doc.name && handleContactUpdate(doc)
if (doc.name) {
capture('contact_created')
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 { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { capture } from '@/telemetry'
import { Switch, createResource } from 'frappe-ui'
import { computed, ref, reactive, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
@ -201,6 +202,7 @@ function createDeal() {
isDealCreating.value = true
},
onSuccess(name) {
capture('deal_created')
isDealCreating.value = false
show.value = false
router.push({ name: 'Deal', params: { dealId: name } })
@ -230,5 +232,8 @@ onMounted(() => {
if (!deal.deal_owner) {
deal.deal_owner = getUser().name
}
if (!deal.status && dealStatuses.value[0].value) {
deal.status = dealStatuses.value[0].value
}
})
</script>

View File

@ -36,6 +36,7 @@
<script setup>
import Link from '@/components/Controls/Link.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { capture } from '@/telemetry'
import { FormControl, call, createResource, TextEditor, DatePicker } from 'frappe-ui'
import { ref, computed, onMounted, h } from 'vue'
@ -115,6 +116,7 @@ function updateValues() {
newValue.value = ''
loading.value = false
show.value = false
capture('bulk_update', { doctype: props.doctype })
emit('reload')
})
}

View File

@ -96,6 +96,7 @@
</template>
<script setup>
import { capture } from '@/telemetry'
import { Checkbox, Select, TextEditor, call } from 'frappe-ui'
import { ref, nextTick, watch } from 'vue'
@ -171,7 +172,10 @@ async function callInsertDoc() {
..._emailTemplate.value,
},
})
doc.name && handleEmailTemplateUpdate(doc)
if (doc.name) {
capture('email_template_created', { doctype: doc.reference_doctype })
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 { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { capture } from '@/telemetry'
import { createResource } from 'frappe-ui'
import { computed, onMounted, ref, reactive, nextTick } from 'vue'
import { useRouter } from 'vue-router'
@ -153,6 +154,7 @@ function createNewLead() {
isLeadCreating.value = true
},
onSuccess(data) {
capture('lead_created')
isLeadCreating.value = false
show.value = false
router.push({ name: 'Lead', params: { leadId: data.name } })
@ -182,5 +184,8 @@ onMounted(() => {
if (!lead.lead_owner) {
lead.lead_owner = getUser().name
}
if (!lead.status && leadStatuses.value[0].value) {
lead.status = leadStatuses.value[0].value
}
})
</script>

View File

@ -66,6 +66,7 @@
<script setup>
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import { capture } from '@/telemetry'
import { TextEditor, call } from 'frappe-ui'
import { ref, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
@ -124,6 +125,7 @@ async function updateNote() {
},
})
if (d.name) {
capture('note_created')
notes.value?.reload()
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 { usersStore } from '@/stores/users'
import { formatNumberIntoCurrency } from '@/utils'
import { capture } from '@/telemetry'
import { call, FeatherIcon, createResource } from 'frappe-ui'
import { ref, nextTick, watch, computed, h } from 'vue'
import { useRouter } from 'vue-router'
@ -157,7 +158,10 @@ async function callInsertDoc() {
},
})
loading.value = false
doc.name && handleOrganizationUpdate(doc)
if (doc.name) {
capture('organization_created')
handleOrganizationUpdate(doc)
}
}
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 { taskStatusOptions, taskPriorityOptions } from '@/utils'
import { usersStore } from '@/stores/users'
import { capture } from '@/telemetry'
import { TextEditor, Dropdown, Tooltip, call, DateTimePicker } from 'frappe-ui'
import { ref, watch, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
@ -199,6 +200,7 @@ async function updateTask() {
},
})
if (d.name) {
capture('task_created')
tasks.value.reload()
}
}

View File

@ -75,7 +75,6 @@ const templates = createListResource({
filters: { status: 'APPROVED', for_doctype: ['in', [props.doctype, '']] },
orderBy: 'modified desc',
pageLength: 99999,
auto: true,
})
onMounted(() => {

View File

@ -18,10 +18,7 @@
<div class="flex gap-1">
<Tooltip :text="__('Mark all as read')">
<div>
<Button
variant="ghost"
@click="() => notificationsStore().mark_as_read.reload()"
>
<Button variant="ghost" @click="() => markAllAsRead()">
<template #icon>
<MarkAsDoneIcon class="h-4 w-4" />
</template>
@ -48,7 +45,7 @@
:key="n.comment"
:to="getRoute(n)"
class="flex cursor-pointer items-start gap-2.5 px-4 py-2.5 hover:bg-gray-100"
@click="mark_as_read(n.comment || n.notification_type_doc)"
@click="markAsRead(n.comment || n.notification_type_doc)"
>
<div class="mt-1 flex items-center gap-2.5">
<div
@ -98,6 +95,7 @@ import { notificationsStore } from '@/stores/notifications'
import { globalStore } from '@/stores/global'
import { timeAgo } from '@/utils'
import { onClickOutside } from '@vueuse/core'
import { capture } from '@/telemetry'
import { Tooltip } from 'frappe-ui'
import { ref, onMounted, onBeforeUnmount } from 'vue'
@ -113,17 +111,23 @@ onClickOutside(
},
{
ignore: ['#notifications-btn'],
}
},
)
function toggleNotificationPanel() {
notificationsStore().toggle()
}
function mark_as_read(doc) {
function markAsRead(doc) {
capture('notification_mark_as_read')
notificationsStore().mark_doc_as_read(doc)
}
function markAllAsRead() {
capture('notification_mark_all_as_read')
notificationsStore().mark_as_read.reload()
}
onBeforeUnmount(() => {
$socket.off('crm_notification')
})

View File

@ -5,7 +5,9 @@
:key="s.label"
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">
<Tooltip v-if="s.tooltipText" :text="__(s.tooltipText)">
<div class="ml-2 cursor-pointer">
@ -43,6 +45,7 @@
import { Dropdown, Tooltip } from 'frappe-ui'
import { timeAgo, dateFormat, formatTime, dateTooltipFormat } from '@/utils'
import { statusesStore } from '@/stores/statuses'
import { capture } from '@/telemetry'
import { computed, defineModel } from 'vue'
const data = defineModel()
@ -58,8 +61,8 @@ let slaSection = computed(() => {
data.value.sla_status == 'Failed'
? 'red'
: data.value.sla_status == 'Fulfilled'
? 'green'
: 'orange'
? 'green'
: 'orange'
if (status == 'First Response Due') {
status = timeAgo(data.value.response_by)
@ -94,11 +97,13 @@ let slaSection = computed(() => {
options: communicationStatuses.data?.map((status) => ({
label: status.name,
value: status.name,
onClick: () =>
emit('updateField', 'communication_status', status.name),
onClick: () => {
capture('sla_status_change')
emit('updateField', 'communication_status', status.name)
},
})),
},
]
],
)
return sections
})

View File

@ -1,14 +1,11 @@
<template>
<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 }">
<div
class="py-2 first:pt-0"
:class="section.hideBorder ? '' : 'border-t first:border-t-0'"
>
<div class="flex items-center justify-between pb-2">
<div class="flex flex-col gap-1.5 p-2.5 bg-gray-50 rounded">
<div class="flex items-center justify-between">
<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
v-if="!section.editingLabel"
@ -39,81 +36,71 @@
</template>
</Dropdown>
</div>
<div>
<Draggable
:list="section.fields"
group="fields"
item-key="label"
class="grid gap-2"
:class="
section.columns ? 'grid-cols-' + section.columns : 'grid-cols-3'
"
handle=".cursor-grab"
>
<template #item="{ element: field }">
<div
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">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
<div>
<Button
variant="ghost"
icon="x"
@click="
section.fields.splice(section.fields.indexOf(field), 1)
"
/>
</div>
<Draggable
:list="section.fields"
group="fields"
item-key="label"
class="grid gap-1.5"
:class="
section.columns ? 'grid-cols-' + section.columns : 'grid-cols-3'
"
handle=".cursor-grab"
>
<template #item="{ element: field }">
<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"
>
<div class="flex items-center gap-2">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
</template>
</Draggable>
<Autocomplete
v-if="fields.data"
value=""
:options="fields.data"
@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'
<Button
variant="ghost"
class="!size-4 rounded-sm"
icon="x"
@click="
section.fields.splice(section.fields.indexOf(field), 1)
"
/>
</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
class="mt-2 w-full !h-[38px] !border-gray-200"
variant="outline"
@click="togglePopover()"
:label="__('Add Field')"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</template>
<template #item-label="{ option }">
<div class="flex flex-col gap-1">
<div>{{ option.label }}</div>
<div class="text-gray-500 text-sm">
{{ `${option.fieldname} - ${option.fieldtype}` }}
</div>
</template>
<template #item-label="{ option }">
<div class="flex flex-col gap-1">
<div>{{ option.label }}</div>
<div class="text-gray-500 text-sm">
{{ `${option.fieldname} - ${option.fieldtype}` }}
</div>
</div>
</template>
</Autocomplete>
</div>
</div>
</template>
</Autocomplete>
</div>
</template>
</Draggable>
<div class="py-2 border-t">
<div class="mt-5.5">
<Button
class="w-full !h-[38px] !border-gray-200"
variant="outline"
class="w-full h-8"
variant="subtle"
:label="__('Add Section')"
@click="
sections.push({

View File

@ -1,62 +1,63 @@
<template>
<Dialog v-model="show" :options="{ size: '3xl' }">
<template #body>
<div class="flex flex-col overflow-hidden h-[calc(100vh_-_8rem)]">
<div class="flex flex-col gap-2 p-8 pb-5">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
<div>{{ __('Edit Quick Entry Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h2>
<div class="flex gap-6 items-end">
<FormControl
class="flex-1"
type="select"
v-model="_doctype"
:label="__('DocType')"
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
@change="reload"
/>
<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>
<template #body-title>
<h3
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-gray-900"
>
<div>{{ __('Edit Quick Entry Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h3>
</template>
<template #body-content>
<div class="flex flex-col gap-3">
<div class="flex justify-between gap-2">
<FormControl
type="select"
class="w-1/4"
v-model="_doctype"
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
@change="reload"
/>
<Switch
v-model="preview"
:label="preview ? __('Hide preview') : __('Show preview')"
size="sm"
/>
</div>
<div v-if="sections?.data" class="overflow-y-auto p-8 pt-3">
<div
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"
>
<QuickEntryLayoutBuilder
v-if="!preview"
:sections="sections.data"
:doctype="_doctype"
/>
<Fields v-else :sections="sections.data" :data="{}" />
</div>
<div v-if="sections?.data">
<QuickEntryLayoutBuilder
v-if="!preview"
:sections="sections.data"
:doctype="_doctype"
/>
<Fields v-else :sections="sections.data" :data="{}" />
</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>
</template>
</Dialog>
</template>
<script setup>
import Fields from '@/components/Fields.vue'
import QuickEntryLayoutBuilder from '@/components/Settings/QuickEntryLayoutBuilder.vue'
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'
const props = defineProps({
@ -121,7 +122,8 @@ function saveChanges() {
},
).then(() => {
loading.value = false
reload()
show.value = false
capture('quick_entry_layout_builder', { doctype: _doctype.value })
})
}
</script>

View File

@ -1,11 +1,13 @@
<template>
<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 }">
<div class="border-b">
<div class="flex items-center justify-between p-2">
<div class="flex flex-col gap-3">
<div
class="flex items-center justify-between rounded px-2.5 py-2 bg-gray-50"
>
<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"
>
<FeatherIcon
@ -26,51 +28,54 @@
<Button
v-if="section.editingLabel"
icon="check"
class="!size-4 rounded-sm"
variant="ghost"
@click.stop="section.editingLabel = false"
/>
</div>
</div>
<div>
<div class="flex gap-1 items-center">
<Button
v-if="!section.editingLabel"
icon="edit"
class="!size-4 rounded-sm"
variant="ghost"
@click="section.editingLabel = true"
/>
>
<EditIcon class="h-3.5" />
</Button>
<Button
v-if="section.editable !== false"
class="!size-4 rounded-sm"
icon="x"
variant="ghost"
@click="sections.splice(sections.indexOf(section), 1)"
/>
</div>
</div>
<div v-show="section.opened" class="p-4 pt-0 pb-2">
<div v-show="section.opened">
<Draggable
:list="section.fields"
group="fields"
item-key="label"
class="flex flex-col gap-1"
class="flex flex-col gap-1.5"
handle=".cursor-grab"
>
<template #item="{ element: field }">
<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">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
<div>
<Button
variant="ghost"
icon="x"
@click="
section.fields.splice(section.fields.indexOf(field), 1)
"
/>
</div>
<Button
variant="ghost"
icon="x"
class="!size-4 rounded-sm"
@click="
section.fields.splice(section.fields.indexOf(field), 1)
"
/>
</div>
</template>
</Draggable>
@ -82,7 +87,7 @@
>
<template #target="{ togglePopover }">
<Button
class="w-full mt-2"
class="w-full h-8 mt-1.5 !border-gray-200 hover:!border-gray-300"
variant="outline"
@click="togglePopover()"
:label="__('Add Field')"
@ -113,10 +118,10 @@
</div>
</template>
</Draggable>
<div class="p-2">
<div class="mt-5.5">
<Button
class="w-full"
variant="outline"
class="w-full h-8"
variant="subtle"
:label="__('Add Section')"
@click="
sections.push({ label: __('New Section'), opened: true, fields: [] })
@ -130,6 +135,7 @@
</div>
</template>
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
import Draggable from 'vuedraggable'

View File

@ -1,67 +1,70 @@
<template>
<Dialog v-model="show" :options="{ size: '3xl' }">
<template #body>
<div ref="parentRef" class="flex h-[calc(100vh_-_8rem)]">
<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>{{ __('Edit 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>
<template #body-title>
<h3
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-gray-900"
>
<div>{{ __('Edit Field Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h3>
</template>
<template #body-content>
<div class="flex flex-col gap-5.5">
<div class="flex justify-between gap-2">
<FormControl
type="select"
class="w-1/4"
v-model="_doctype"
:options="['CRM Lead', 'CRM Deal']"
@change="reload"
/>
<Switch
v-model="preview"
:label="preview ? __('Hide preview') : __('Show preview')"
size="sm"
/>
</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">
<SidePanelLayoutBuilder
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 v-if="sections.data" class="flex gap-4">
<SidePanelLayoutBuilder
class="flex flex-1 flex-col pr-2"
:sections="sections.data"
:doctype="_doctype"
/>
<div v-if="preview" class="flex flex-1 flex-col border rounded">
<div
v-for="(section, i) in sections.data"
:key="section.label"
class="flex flex-col py-1.5 px-1"
: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>
</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>
</template>
</Dialog>
@ -69,10 +72,10 @@
<script setup>
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import Resizer from '@/components/Resizer.vue'
import SidePanelLayoutBuilder from '@/components/Settings/SidePanelLayoutBuilder.vue'
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'
const props = defineProps({
@ -82,9 +85,10 @@ const props = defineProps({
},
})
const emit = defineEmits(['reload'])
const show = defineModel()
const _doctype = ref(props.doctype)
const parentRef = ref(null)
const loading = ref(false)
const dirty = ref(false)
const preview = ref(false)
@ -139,7 +143,9 @@ function saveChanges() {
},
).then(() => {
loading.value = false
reload()
show.value = false
capture('side_panel_layout_builder', { doctype: _doctype.value })
emit('reload')
})
}
</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
v-if="typeof icon == 'string'"
: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>
</slot>
</Tooltip>
<Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
<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="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'

View File

@ -50,10 +50,11 @@
<script setup>
import SettingsModal from '@/components/Settings/SettingsModal.vue'
import CRMLogo from '@/components/Icons/CRMLogo.vue'
import Apps from '@/components/Apps.vue'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/users'
import { Dropdown } from 'frappe-ui'
import { computed, ref } from 'vue'
import { computed, ref, markRaw} from 'vue'
const props = defineProps({
isCollapsed: {
@ -75,9 +76,7 @@ let dropdownOptions = ref([
hideLabel: true,
items: [
{
icon: 'corner-up-left',
label: computed(() => __('Switch to Desk')),
onClick: () => window.location.replace('/app'),
component: markRaw(Apps),
},
{
icon: 'life-buoy',

View File

@ -287,7 +287,13 @@ import { globalStore } from '@/stores/global'
import { viewsStore } from '@/stores/views'
import { usersStore } from '@/stores/users'
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 { useRouter, useRoute } from 'vue-router'
import { useDebounceFn } from '@vueuse/core'
@ -356,6 +362,19 @@ const currentView = computed(() => {
label:
_view?.label || props.options?.defaultViewName || getViewType().label,
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) {
let columns = list.value.data.kanban_columns || "[]"
let columns = list.value.data.kanban_columns || '[]'
if (typeof columns === 'string') {
columns = JSON.parse(columns)

View File

@ -1,10 +1,5 @@
import './index.css'
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
import { createPinia } from 'pinia'
import {
FrappeUI,
Button,
@ -19,9 +14,14 @@ import {
frappeRequest,
FeatherIcon,
} from 'frappe-ui'
import translationPlugin from './translation'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createDialog } from './utils/dialogs'
import { initSocket } from './socket'
import router from './router'
import translationPlugin from './translation'
import { posthogPlugin } from './telemetry'
import App from './App.vue'
let globalComponents = {
Button,
@ -45,6 +45,7 @@ app.use(FrappeUI)
app.use(pinia)
app.use(router)
app.use(translationPlugin)
app.use(posthogPlugin)
for (let key in globalComponents) {
app.component(key, globalComponents[key])
}
@ -61,7 +62,7 @@ if (import.meta.env.DEV) {
socket = initSocket()
app.config.globalProperties.$socket = socket
app.mount('#app')
}
},
)
} else {
socket = initSocket()

View File

@ -224,6 +224,7 @@ import {
Tabs,
call,
createResource,
usePageMeta,
} from 'frappe-ui'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
@ -268,6 +269,22 @@ const showContactModal = ref(false)
const showQuickEntryModal = 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(() => {
let items = [{ label: __('Contacts'), route: { name: 'Contacts' } }]
items.push({
@ -277,6 +294,13 @@ const breadcrumbs = computed(() => {
return items
})
usePageMeta(() => {
return {
title: contact.data?.full_name || contact.data?.name,
}
})
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
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({
url: 'crm.api.contact.get_linked_deals',
cache: ['deals', props.contactId],

View File

@ -174,8 +174,8 @@
<span>{{ __('Loading...') }}</span>
</div>
<div
v-else-if="section.contacts.length"
v-for="(contact, i) in section.contacts"
v-else-if="deal_contacts?.data?.length"
v-for="(contact, i) in deal_contacts.data"
:key="contact.name"
>
<div
@ -251,7 +251,7 @@
</Section>
</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"
/>
</div>
@ -298,6 +298,7 @@
v-if="showSidePanelModal"
v-model="showSidePanelModal"
doctype="CRM Deal"
@reload="() => fieldsLayout.reload()"
/>
</template>
<script setup>
@ -349,6 +350,7 @@ import {
Tabs,
Breadcrumbs,
call,
usePageMeta,
} from 'frappe-ui'
import { ref, computed, h, onMounted } from 'vue'
import { useRouter } from 'vue-router'
@ -457,6 +459,12 @@ const breadcrumbs = computed(() => {
return items
})
usePageMeta(() => {
return {
title: organization.value?.name || deal.data?.name,
}
})
const tabIndex = ref(0)
const tabs = computed(() => {
let tabOptions = [
@ -603,22 +611,11 @@ const deal_contacts = createResource({
params: { name: props.dealId },
cache: ['deal_contacts', props.dealId],
auto: true,
onSuccess: (data) => {
let contactSection = fieldsLayout.data?.find(
(section) => section.name == 'contacts_section',
)
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,
}
transform: (data) => {
data.forEach((contact) => {
contact.opened = false
})
return data
},
})

View File

@ -11,7 +11,7 @@
<Button
variant="solid"
:label="__('Create')"
@click="showEmailTemplateModal = true"
@click="() => showEmailTemplate()"
>
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
</Button>
@ -55,7 +55,7 @@
>
<Email2Icon class="h-10 w-10" />
<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>
</Button>
</div>
@ -115,28 +115,32 @@ const rows = computed(() => {
const showEmailTemplateModal = ref(false)
const emailTemplate = ref({
subject: '',
response: '',
response_html: '',
name: '',
enabled: 1,
use_html: 0,
owner: '',
reference_doctype: 'CRM Deal',
})
const emailTemplate = ref({})
function showEmailTemplate(name) {
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,
if (!name) {
emailTemplate.value = {
subject: '',
response: '',
response_html: '',
name: '',
enabled: 1,
use_html: 0,
owner: '',
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
}

View File

@ -266,7 +266,11 @@
</div>
</template>
</Dialog>
<SidePanelModal v-if="showSidePanelModal" v-model="showSidePanelModal" />
<SidePanelModal
v-if="showSidePanelModal"
v-model="showSidePanelModal"
@reload="() => fieldsLayout.reload()"
/>
</template>
<script setup>
import Resizer from '@/components/Resizer.vue'
@ -308,6 +312,7 @@ import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
import { usersStore } from '@/stores/users'
import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { capture } from '@/telemetry'
import {
createResource,
FileUploader,
@ -318,6 +323,7 @@ import {
Switch,
Breadcrumbs,
call,
usePageMeta,
} from 'frappe-ui'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
@ -422,6 +428,12 @@ const breadcrumbs = computed(() => {
return items
})
usePageMeta(() => {
return {
title: lead.data?.lead_name || lead.data?.name,
}
})
const tabIndex = ref(0)
const tabs = computed(() => {
@ -576,6 +588,7 @@ async function convertToDeal(updated) {
},
)
if (deal) {
capture('convert_lead_to_deal')
if (updated) {
await organizations.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,
createListResource,
createDocumentResource,
usePageMeta,
} from 'frappe-ui'
import LayoutHeader from '@/components/LayoutHeader.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
@ -295,6 +296,12 @@ const breadcrumbs = computed(() => {
return items
})
usePageMeta(() => {
return {
title: props.organizationId,
}
})
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
if (!['png', 'jpg', 'jpeg'].includes(extn)) {

View File

@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { usersStore } from '@/stores/users'
import { userResource } from '@/stores/user'
import { sessionStore } from '@/stores/session'
const routes = [
@ -102,11 +102,6 @@ const routes = [
name: 'Invalid Page',
component: () => import('@/pages/InvalidPage.vue'),
},
{
path: '/login',
name: 'Login',
component: () => import('@/pages/Login.vue'),
},
]
const handleMobileView = (componentName) => {
@ -139,19 +134,18 @@ let router = createRouter({
})
router.beforeEach(async (to, from, next) => {
const { users } = usersStore()
const { isLoggedIn } = sessionStore()
isLoggedIn && (await users.promise)
isLoggedIn && (await userResource.promise)
if (from.meta?.scrollPos) {
from.meta.scrollPos.top = document.querySelector('#list-rows')?.scrollTop
}
if (to.name === 'Login' && isLoggedIn) {
if (to.name === 'Home' && isLoggedIn) {
next({ name: 'Leads' })
} else if (to.name !== 'Login' && !isLoggedIn) {
next({ name: 'Login' })
} else if (!isLoggedIn) {
window.location.href = "/login?redirect-to=/crm";
} else if (to.matched.length === 0) {
next({ name: 'Invalid Page' })
} else {

View File

@ -1,12 +1,10 @@
import { defineStore } from 'pinia'
import { createResource } from 'frappe-ui'
import { usersStore } from './users'
import { userResource } from './user'
import router from '@/router'
import { ref, computed } from 'vue'
export const sessionStore = defineStore('crm-session', () => {
const { users } = usersStore()
function sessionUser() {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
let _sessionUser = cookies.get('user_id')
@ -25,7 +23,7 @@ export const sessionStore = defineStore('crm-session', () => {
throw new Error('Invalid email or password')
},
onSuccess() {
users.reload()
userResource.reload()
user.value = sessionUser()
login.reset()
router.replace({ path: '/' })
@ -35,9 +33,9 @@ export const sessionStore = defineStore('crm-session', () => {
const logout = createResource({
url: 'logout',
onSuccess() {
users.reset()
userResource.reset()
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 { createListResource } from 'frappe-ui'
import { reactive, h } from 'vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
export const statusesStore = defineStore('crm-statuses', () => {
let leadStatusesByName = reactive({})
@ -103,6 +104,7 @@ export const statusesStore = defineStore('crm-statuses', () => {
class: statusesByName[status].iconColorClass,
}),
onClick: () => {
capture('status_changed', { doctype, status })
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,
}