Merge pull request #317 from frappe/develop
chore: Merge develop to main
This commit is contained in:
commit
bc83749552
@ -2,6 +2,7 @@ from bs4 import BeautifulSoup
|
||||
import frappe
|
||||
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(),
|
||||
}
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
14
crm/hooks.py
14
crm/hooks.py
@ -4,11 +4,23 @@ app_publisher = "Frappe Technologies Pvt. Ltd."
|
||||
app_description = "Kick-ass Open Source CRM"
|
||||
app_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
BIN
crm/public/images/desk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@ -1 +1 @@
|
||||
Subproject commit b5cc6c0cd36ee25e8e945e8c91d8b5c035fb5e44
|
||||
Subproject commit cf4e7d347237c23ebde5f5c890abdf7e81284961
|
||||
@ -14,7 +14,7 @@
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"@vueuse/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",
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
@ -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() {
|
||||
|
||||
79
frontend/src/components/Apps.vue
Normal file
79
frontend/src/components/Apps.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<Popover placement="right-start" class="flex w-full">
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
active ? 'bg-gray-100' : 'text-gray-800',
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base hover:bg-gray-100',
|
||||
]"
|
||||
@click.prevent="togglePopover()"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<AppsIcon class="size-4" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{ __('Apps') }}
|
||||
</span>
|
||||
</div>
|
||||
<FeatherIcon name="chevron-right" class="size-4 text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||
>
|
||||
<div v-for="app in apps.data" :key="app.name">
|
||||
<a
|
||||
:href="app.route"
|
||||
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-1 hover:bg-gray-100"
|
||||
>
|
||||
<img class="size-8" :src="app.logo" />
|
||||
<div class="text-sm text-gray-700" @click="app.onClick">
|
||||
{{ app.title }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppsIcon from '@/components/Icons/AppsIcon.vue'
|
||||
import { Popover, createResource } from 'frappe-ui'
|
||||
import { onUnmounted } from 'vue';
|
||||
import { stopRecording } from '@/telemetry';
|
||||
|
||||
const props = defineProps({
|
||||
active: Boolean,
|
||||
})
|
||||
|
||||
const apps = createResource({
|
||||
url: 'frappe.apps.get_apps',
|
||||
cache: 'apps',
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
let _apps = [
|
||||
{
|
||||
name: 'frappe',
|
||||
logo: '/assets/frappe/images/framework.png',
|
||||
title: __('Desk'),
|
||||
route: '/app',
|
||||
},
|
||||
]
|
||||
data.map((app) => {
|
||||
if (app.name === 'crm') return
|
||||
_apps.push({
|
||||
name: app.name,
|
||||
logo: app.logo,
|
||||
title: __(app.title),
|
||||
route: app.route,
|
||||
})
|
||||
})
|
||||
|
||||
return _apps
|
||||
},
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRecording()
|
||||
})
|
||||
</script>
|
||||
@ -197,6 +197,7 @@ import { Device } from '@twilio/voice-sdk'
|
||||
import { useDraggable, useWindowSize } from '@vueuse/core'
|
||||
import { 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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
17
frontend/src/components/Icons/AppsIcon.vue
Normal file
17
frontend/src/components/Icons/AppsIcon.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
fill="none"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
class=""
|
||||
clip-rule="evenodd"
|
||||
d="M13.8496 4.69692L12.0062 6.54029C11.8109 6.73555 11.4944 6.73555 11.2991 6.54028L9.45572 4.69692C9.26046 4.50166 9.26046 4.18508 9.45572 3.98981L11.2991 2.14645C11.4944 1.95118 11.8109 1.95118 12.0062 2.14645L13.8496 3.98981C14.0448 4.18507 14.0448 4.50166 13.8496 4.69692ZM14.5567 3.28271C15.1425 3.86849 15.1425 4.81824 14.5567 5.40403L12.7133 7.24739C12.1275 7.83318 11.1778 7.83318 10.592 7.24739L8.74862 5.40402C8.16283 4.81824 8.16283 3.86849 8.74862 3.28271L10.592 1.43934C11.1778 0.853553 12.1275 0.853554 12.7133 1.43934L14.5567 3.28271ZM5.60691 4.34338C5.60691 5.3394 4.79948 6.14683 3.80346 6.14683C2.80743 6.14683 2 5.3394 2 4.34338C2 3.34736 2.80743 2.53992 3.80346 2.53992C4.79948 2.53992 5.60691 3.34736 5.60691 4.34338ZM6.60691 4.34338C6.60691 5.89168 5.35176 7.14683 3.80346 7.14683C2.25515 7.14683 1 5.89168 1 4.34338C1 2.79507 2.25515 1.53992 3.80346 1.53992C5.35176 1.53992 6.60691 2.79507 6.60691 4.34338ZM12.9565 10.3897H10.3495C10.0734 10.3897 9.84954 10.6136 9.84954 10.8897V13.4966C9.84954 13.7728 10.0734 13.9966 10.3495 13.9966H12.9565C13.2326 13.9966 13.4565 13.7728 13.4565 13.4966V10.8897C13.4565 10.6136 13.2326 10.3897 12.9565 10.3897ZM10.3495 9.38971C9.52112 9.38971 8.84954 10.0613 8.84954 10.8897V13.4966C8.84954 14.325 9.52111 14.9966 10.3495 14.9966H12.9565C13.7849 14.9966 14.4565 14.325 14.4565 13.4966V10.8897C14.4565 10.0613 13.7849 9.38971 12.9565 9.38971H10.3495ZM2.5 10.3897H5.10691C5.38305 10.3897 5.60691 10.6136 5.60691 10.8897V13.4966C5.60691 13.7728 5.38306 13.9966 5.10691 13.9966H2.5C2.22386 13.9966 2 13.7728 2 13.4966V10.8897C2 10.6136 2.22386 10.3897 2.5 10.3897ZM1 10.8897C1 10.0613 1.67157 9.38971 2.5 9.38971H5.10691C5.93534 9.38971 6.60691 10.0613 6.60691 10.8897V13.4966C6.60691 14.325 5.93534 14.9966 5.10691 14.9966H2.5C1.67157 14.9966 1 14.325 1 13.4966V10.8897Z"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -21,6 +21,7 @@ import EditValueModal from '@/components/Modals/EditValueModal.vue'
|
||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||
import { 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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +75,6 @@ const templates = createListResource({
|
||||
filters: { status: 'APPROVED', for_doctype: ['in', [props.doctype, '']] },
|
||||
orderBy: 'modified desc',
|
||||
pageLength: 99999,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,131 +0,0 @@
|
||||
<template>
|
||||
<div ref="parentRef" class="flex h-full">
|
||||
<div class="flex-1 flex flex-col justify-between gap-2 p-8">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
|
||||
<div>{{ __('Sidebar Fields Layout') }}</div>
|
||||
<Badge
|
||||
v-if="dirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="doctype"
|
||||
:label="__('DocType')"
|
||||
:options="['CRM Lead', 'CRM Deal']"
|
||||
@change="reload"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
<Button
|
||||
:label="preview ? __('Hide Preview') : __('Show Preview')"
|
||||
@click="preview = !preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Resizer
|
||||
v-if="sections.data"
|
||||
class="flex flex-col justify-between border-l"
|
||||
:parent="parentRef"
|
||||
side="right"
|
||||
>
|
||||
<div class="flex flex-1 flex-col justify-between overflow-hidden">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<SidebarLayoutBuilder
|
||||
v-if="!preview"
|
||||
:sections="sections.data"
|
||||
:doctype="doctype"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
v-for="(section, i) in sections.data"
|
||||
:key="section.label"
|
||||
class="flex flex-col p-3"
|
||||
:class="{ 'border-b': i !== sections.data.length - 1 }"
|
||||
>
|
||||
<Section :is-opened="section.opened" :label="section.label">
|
||||
<SectionFields :fields="section.fields" v-model="data" />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Resizer>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Section from '@/components/Section.vue'
|
||||
import SectionFields from '@/components/SectionFields.vue'
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import SidebarLayoutBuilder from '@/components/Settings/SidebarLayoutBuilder.vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { Badge, call, createResource } from 'frappe-ui'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const parentRef = ref(null)
|
||||
const doctype = ref('CRM Lead')
|
||||
const loading = ref(false)
|
||||
const dirty = ref(false)
|
||||
const preview = ref(false)
|
||||
const data = ref({})
|
||||
|
||||
function getParams() {
|
||||
return { doctype: doctype.value, type: 'Side Panel' }
|
||||
}
|
||||
|
||||
const sections = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
cache: ['sidebar-sections', doctype.value],
|
||||
params: getParams(),
|
||||
onSuccess(data) {
|
||||
sections.originalData = JSON.parse(JSON.stringify(data))
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => sections?.data,
|
||||
() => {
|
||||
dirty.value =
|
||||
JSON.stringify(sections?.data) !== JSON.stringify(sections?.originalData)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => useDebounceFn(reload, 100)())
|
||||
|
||||
function reload() {
|
||||
sections.params = getParams()
|
||||
sections.reload()
|
||||
}
|
||||
|
||||
function saveChanges() {
|
||||
let _sections = JSON.parse(JSON.stringify(sections.data))
|
||||
_sections.forEach((section) => {
|
||||
if (!section.fields) return
|
||||
section.fields = section.fields.map(
|
||||
(field) => field.fieldname || field.name,
|
||||
)
|
||||
})
|
||||
loading.value = true
|
||||
call(
|
||||
'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.save_fields_layout',
|
||||
{
|
||||
doctype: doctype.value,
|
||||
type: 'Side Panel',
|
||||
layout: JSON.stringify(_sections),
|
||||
},
|
||||
).then(() => {
|
||||
loading.value = false
|
||||
reload()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -15,15 +15,15 @@
|
||||
<FeatherIcon
|
||||
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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-screen w-screen justify-center bg-gray-100">
|
||||
<div class="mt-32 w-full px-4">
|
||||
<CRMLogo class="mx-auto h-10" />
|
||||
<div class="mt-6 flex items-center justify-center space-x-1.5">
|
||||
<span class="text-3xl font-semibold text-gray-900">Login to CRM</span>
|
||||
</div>
|
||||
<div class="mx-auto mt-6 w-full px-4 sm:w-96">
|
||||
<form
|
||||
v-if="showEmailLogin"
|
||||
method="POST"
|
||||
action="/api/method/login"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div>
|
||||
<FormControl
|
||||
variant="outline"
|
||||
size="md"
|
||||
:type="
|
||||
(email || '').toLowerCase() === 'administrator'
|
||||
? 'text'
|
||||
: 'email'
|
||||
"
|
||||
label="Email"
|
||||
v-model="email"
|
||||
placeholder="jane@example.com"
|
||||
:disabled="session.login.loading"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<FormControl
|
||||
variant="outline"
|
||||
size="md"
|
||||
label="Password"
|
||||
v-model="password"
|
||||
placeholder="••••••"
|
||||
:disabled="session.login.loading"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2" :message="session.login.error" />
|
||||
<Button
|
||||
variant="solid"
|
||||
class="mt-6 w-full"
|
||||
:loading="session.login.loading"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
<button
|
||||
v-if="authProviders.data.length"
|
||||
class="mt-2 w-full py-2 text-base text-gray-600"
|
||||
@click="showEmailLogin = false"
|
||||
>
|
||||
Login using other methods
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
class="mx-auto space-y-2"
|
||||
v-if="authProviders.data && !showEmailLogin"
|
||||
>
|
||||
<Button @click="showEmailLogin = true" variant="solid" class="w-full">
|
||||
Login via email
|
||||
</Button>
|
||||
<a
|
||||
class="flex justify-center items-center gap-2 w-full rounded border bg-gray-900 px-3 py-1 text-center text-base h-7 focus:outline-none focus:ring-2 focus:ring-gray-400 text-white transition-colors hover:bg-gray-700"
|
||||
v-for="provider in authProviders.data"
|
||||
:key="provider.name"
|
||||
:href="provider.auth_url"
|
||||
>
|
||||
<div v-if="provider.icon" v-html="provider.icon" />
|
||||
Login via {{ provider.provider_name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import CRMLogo from '@/components/Icons/CRMLogo.vue';
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const session = sessionStore()
|
||||
let showEmailLogin = ref(false)
|
||||
let email = ref('')
|
||||
let password = ref('')
|
||||
|
||||
let authProviders = createResource({
|
||||
url: 'crm.api.auth.oauth_providers',
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
showEmailLogin.value = data.length === 0
|
||||
},
|
||||
})
|
||||
authProviders.fetch()
|
||||
|
||||
function submit() {
|
||||
session.login.submit({
|
||||
usr: email.value,
|
||||
pwd: password.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -235,6 +235,7 @@ import {
|
||||
call,
|
||||
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)) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' })
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
12
frontend/src/stores/user.js
Normal file
12
frontend/src/stores/user.js
Normal file
@ -0,0 +1,12 @@
|
||||
import router from '@/router'
|
||||
import { createResource } from 'frappe-ui'
|
||||
|
||||
export const userResource = createResource({
|
||||
url: 'frappe.auth.get_logged_user',
|
||||
cache: 'User',
|
||||
onError(error) {
|
||||
if (error && error.exc_type === 'AuthenticationError') {
|
||||
router.push({ name: 'Home' })
|
||||
}
|
||||
},
|
||||
})
|
||||
97
frontend/src/telemetry.ts
Normal file
97
frontend/src/telemetry.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import '../../../frappe/frappe/public/js/lib/posthog.js'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
posthog: any
|
||||
}
|
||||
}
|
||||
type PosthogSettings = {
|
||||
posthog_project_id: string
|
||||
posthog_host: string
|
||||
enable_telemetry: boolean
|
||||
telemetry_site_age: number
|
||||
}
|
||||
interface CaptureOptions {
|
||||
data: {
|
||||
user: string
|
||||
[key: string]: string | number | boolean | object
|
||||
}
|
||||
}
|
||||
|
||||
let posthog: typeof window.posthog = window.posthog
|
||||
|
||||
// Posthog Settings
|
||||
let posthogSettings = createResource({
|
||||
url: 'crm.api.get_posthog_settings',
|
||||
cache: 'posthog_settings',
|
||||
onSuccess: (ps: PosthogSettings) => initPosthog(ps),
|
||||
})
|
||||
|
||||
let isTelemetryEnabled = () => {
|
||||
if (!posthogSettings.data) return false
|
||||
|
||||
return (
|
||||
posthogSettings.data.enable_telemetry &&
|
||||
posthogSettings.data.posthog_project_id &&
|
||||
posthogSettings.data.posthog_host
|
||||
)
|
||||
}
|
||||
|
||||
// Posthog Initialization
|
||||
function initPosthog(ps: PosthogSettings) {
|
||||
if (!isTelemetryEnabled()) return
|
||||
|
||||
posthog.init(ps.posthog_project_id, {
|
||||
api_host: ps.posthog_host,
|
||||
person_profiles: 'identified_only',
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
capture_pageleave: true,
|
||||
enable_heatmaps: false,
|
||||
disable_session_recording: false,
|
||||
loaded: (ph: typeof posthog) => {
|
||||
window.posthog = ph
|
||||
ph.identify(window.location.hostname)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Posthog Functions
|
||||
function capture(
|
||||
event: string,
|
||||
options: CaptureOptions = { data: { user: '' } },
|
||||
) {
|
||||
if (!isTelemetryEnabled()) return
|
||||
window.posthog.capture(`crm_${event}`, options)
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
if (!isTelemetryEnabled()) return
|
||||
if (window.posthog?.__loaded) {
|
||||
window.posthog.startSessionRecording()
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!isTelemetryEnabled()) return
|
||||
if (window.posthog?.__loaded && window.posthog.sessionRecordingStarted()) {
|
||||
window.posthog.stopSessionRecording()
|
||||
}
|
||||
}
|
||||
|
||||
// Posthog Plugin
|
||||
function posthogPlugin(app: any) {
|
||||
app.config.globalProperties.posthog = posthog
|
||||
if (!window.posthog?.length) posthogSettings.fetch()
|
||||
}
|
||||
|
||||
export {
|
||||
posthog,
|
||||
posthogSettings,
|
||||
posthogPlugin,
|
||||
capture,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user