Merge branch 'develop' into splashscreen-size
This commit is contained in:
commit
f6ee8db98a
@ -1,7 +1,7 @@
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.translate import get_all_translations
|
from frappe.translate import get_all_translations
|
||||||
from frappe.utils import cstr
|
from frappe.utils import validate_email_address, split_emails, cstr
|
||||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||||
|
|
||||||
|
|
||||||
@ -66,4 +66,44 @@ def check_app_permission():
|
|||||||
if any(role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles):
|
if any(role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
def accept_invitation(key: str = None):
|
||||||
|
if not key:
|
||||||
|
frappe.throw("Invalid or expired key")
|
||||||
|
|
||||||
|
result = frappe.db.get_all("CRM Invitation", filters={"key": key}, pluck="name")
|
||||||
|
if not result:
|
||||||
|
frappe.throw("Invalid or expired key")
|
||||||
|
|
||||||
|
invitation = frappe.get_doc("CRM Invitation", result[0])
|
||||||
|
invitation.accept()
|
||||||
|
invitation.reload()
|
||||||
|
|
||||||
|
if invitation.status == "Accepted":
|
||||||
|
frappe.local.login_manager.login_as(invitation.email)
|
||||||
|
frappe.local.response["type"] = "redirect"
|
||||||
|
frappe.local.response["location"] = "/crm"
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def invite_by_email(emails: str, role: str):
|
||||||
|
if not emails:
|
||||||
|
return
|
||||||
|
email_string = validate_email_address(emails, throw=False)
|
||||||
|
email_list = split_emails(email_string)
|
||||||
|
if not email_list:
|
||||||
|
return
|
||||||
|
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
|
||||||
|
existing_invites = frappe.db.get_all(
|
||||||
|
"CRM Invitation",
|
||||||
|
filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]},
|
||||||
|
pluck="email",
|
||||||
|
)
|
||||||
|
|
||||||
|
to_invite = list(set(email_list) - set(existing_members) - set(existing_invites))
|
||||||
|
|
||||||
|
for email in to_invite:
|
||||||
|
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
||||||
|
|||||||
0
crm/fcrm/doctype/crm_invitation/__init__.py
Normal file
0
crm/fcrm/doctype/crm_invitation/__init__.py
Normal file
12
crm/fcrm/doctype/crm_invitation/crm_invitation.js
Normal file
12
crm/fcrm/doctype/crm_invitation/crm_invitation.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on("CRM Invitation", {
|
||||||
|
refresh(frm) {
|
||||||
|
if (frm.doc.status != "Accepted") {
|
||||||
|
frm.add_custom_button(__("Accept Invitation"), () => {
|
||||||
|
return frm.call("accept_invitation");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
112
crm/fcrm/doctype/crm_invitation/crm_invitation.json
Normal file
112
crm/fcrm/doctype/crm_invitation/crm_invitation.json
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2024-09-03 12:19:18.933810",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"email",
|
||||||
|
"role",
|
||||||
|
"key",
|
||||||
|
"invited_by",
|
||||||
|
"column_break_dsuz",
|
||||||
|
"status",
|
||||||
|
"email_sent_at",
|
||||||
|
"accepted_at"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "email",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Email",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "role",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Role",
|
||||||
|
"options": "\nSales User\nSales Manager",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "key",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "invited_by",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Invited By",
|
||||||
|
"options": "User"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_dsuz",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "status",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Status",
|
||||||
|
"options": "\nPending\nAccepted\nExpired"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "email_sent_at",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "Email Sent At"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "accepted_at",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "Accepted At"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2024-09-03 14:59:29.450018",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "CRM Invitation",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales User",
|
||||||
|
"share": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
79
crm/fcrm/doctype/crm_invitation/crm_invitation.py
Normal file
79
crm/fcrm/doctype/crm_invitation/crm_invitation.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class CRMInvitation(Document):
|
||||||
|
def before_insert(self):
|
||||||
|
frappe.utils.validate_email_address(self.email, True)
|
||||||
|
|
||||||
|
self.key = frappe.generate_hash(length=12)
|
||||||
|
self.invited_by = frappe.session.user
|
||||||
|
self.status = "Pending"
|
||||||
|
|
||||||
|
def after_insert(self):
|
||||||
|
self.invite_via_email()
|
||||||
|
|
||||||
|
def invite_via_email(self):
|
||||||
|
invite_link = frappe.utils.get_url(f"/api/method/crm.api.accept_invitation?key={self.key}")
|
||||||
|
if frappe.local.dev_server:
|
||||||
|
print(f"Invite link for {self.email}: {invite_link}")
|
||||||
|
|
||||||
|
title = f"Frappe CRM"
|
||||||
|
template = "crm_invitation"
|
||||||
|
|
||||||
|
frappe.sendmail(
|
||||||
|
recipients=self.email,
|
||||||
|
subject=f"You have been invited to join {title}",
|
||||||
|
template=template,
|
||||||
|
args={"title": title, "invite_link": invite_link},
|
||||||
|
now=True,
|
||||||
|
)
|
||||||
|
self.db_set("email_sent_at", frappe.utils.now())
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def accept_invitation(self):
|
||||||
|
frappe.only_for("System Manager")
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def accept(self):
|
||||||
|
if self.status == "Expired":
|
||||||
|
frappe.throw("Invalid or expired key")
|
||||||
|
|
||||||
|
user = self.create_user_if_not_exists()
|
||||||
|
user.append_roles(self.role)
|
||||||
|
user.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
self.status = "Accepted"
|
||||||
|
self.accepted_at = frappe.utils.now()
|
||||||
|
self.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
def create_user_if_not_exists(self):
|
||||||
|
if not frappe.db.exists("User", self.email):
|
||||||
|
first_name = self.email.split("@")[0].title()
|
||||||
|
user = frappe.get_doc(
|
||||||
|
doctype="User",
|
||||||
|
user_type="System User",
|
||||||
|
email=self.email,
|
||||||
|
send_welcome_email=0,
|
||||||
|
first_name=first_name,
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
else:
|
||||||
|
user = frappe.get_doc("User", self.email)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def expire_invitations():
|
||||||
|
"""expire invitations after 3 days"""
|
||||||
|
from frappe.utils import add_days, now
|
||||||
|
|
||||||
|
days = 3
|
||||||
|
invitations_to_expire = frappe.db.get_all(
|
||||||
|
"CRM Invitation", filters={"status": "Pending", "creation": ["<", add_days(now(), -days)]}
|
||||||
|
)
|
||||||
|
for invitation in invitations_to_expire:
|
||||||
|
invitation = frappe.get_doc("CRM Invitation", invitation.name)
|
||||||
|
invitation.status = "Expired"
|
||||||
|
invitation.save(ignore_permissions=True)
|
||||||
9
crm/fcrm/doctype/crm_invitation/test_crm_invitation.py
Normal file
9
crm/fcrm/doctype/crm_invitation/test_crm_invitation.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestCRMInvitation(FrappeTestCase):
|
||||||
|
pass
|
||||||
4
crm/templates/emails/crm_invitation.html
Normal file
4
crm/templates/emails/crm_invitation.html
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<h2>You have been invited to join Frappe CRM</h2>
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a>
|
||||||
|
</p>
|
||||||
@ -51,7 +51,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1 text-base text-gray-800">
|
<div class="flex flex-col gap-1 text-base leading-5 text-gray-800">
|
||||||
|
<div>{{ activity.data.subject }}</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="mr-1 text-gray-600"> {{ __('To') }}: </span>
|
<span class="mr-1 text-gray-600"> {{ __('To') }}: </span>
|
||||||
<span>{{ activity.data.recipients }}</span>
|
<span>{{ activity.data.recipients }}</span>
|
||||||
@ -66,12 +67,8 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
|
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span class="mr-1 text-gray-600"> {{ __('Subject') }}: </span>
|
|
||||||
<span>{{ activity.data.subject }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="border-0 border-t my-3.5 border-gray-200" />
|
<div class="border-0 border-t mt-3 mb-1 border-gray-200" />
|
||||||
<EmailContent :content="activity.data.content" />
|
<EmailContent :content="activity.data.content" />
|
||||||
<div v-if="activity.data?.attachments?.length" class="flex flex-wrap gap-2">
|
<div v-if="activity.data?.attachments?.length" class="flex flex-wrap gap-2">
|
||||||
<AttachmentItem
|
<AttachmentItem
|
||||||
@ -108,6 +105,8 @@ function reply(email, reply_all = false) {
|
|||||||
|
|
||||||
if (!email.subject.startsWith('Re:')) {
|
if (!email.subject.startsWith('Re:')) {
|
||||||
editor.subject = `Re: ${email.subject}`
|
editor.subject = `Re: ${email.subject}`
|
||||||
|
} else {
|
||||||
|
editor.subject = email.subject
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reply_all) {
|
if (reply_all) {
|
||||||
|
|||||||
120
frontend/src/components/Controls/MultiValueInput.vue
Normal file
120
frontend/src/components/Controls/MultiValueInput.vue
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-1 min-h-20 p-1.5 cursor-text rounded h-7 text-base border border-gray-300 bg-white hover:border-gray-400 focus:border-gray-500 focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-800 transition-colors w-full"
|
||||||
|
@click="setFocus"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
ref="emails"
|
||||||
|
v-for="value in values"
|
||||||
|
:key="value"
|
||||||
|
:label="value"
|
||||||
|
theme="gray"
|
||||||
|
variant="subtle"
|
||||||
|
class="rounded"
|
||||||
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<FeatherIcon
|
||||||
|
class="h-3.5"
|
||||||
|
name="x"
|
||||||
|
@click.stop="removeValue(value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextInput
|
||||||
|
ref="search"
|
||||||
|
class="w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||||
|
type="text"
|
||||||
|
v-model="query"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
@keydown.enter.capture.stop="addValue()"
|
||||||
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { TextInput } from 'frappe-ui'
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
validate: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: Function,
|
||||||
|
default: (value) => `${value} is an Invalid value`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = defineModel()
|
||||||
|
|
||||||
|
const emails = ref([])
|
||||||
|
const search = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
const query = ref('')
|
||||||
|
|
||||||
|
const addValue = () => {
|
||||||
|
let value = query.value
|
||||||
|
error.value = null
|
||||||
|
if (value) {
|
||||||
|
const splitValues = value.split(',')
|
||||||
|
splitValues.forEach((value) => {
|
||||||
|
value = value.trim()
|
||||||
|
if (value) {
|
||||||
|
// check if value is not already in the values array
|
||||||
|
if (!values.value?.includes(value)) {
|
||||||
|
// check if value is valid
|
||||||
|
if (value && props.validate && !props.validate(value)) {
|
||||||
|
error.value = props.errorMessage(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// add value to values array
|
||||||
|
if (!values.value) {
|
||||||
|
values.value = [value]
|
||||||
|
} else {
|
||||||
|
values.value.push(value)
|
||||||
|
}
|
||||||
|
value = value.replace(value, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
!error.value && (query.value = '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeValue = (value) => {
|
||||||
|
values.value = values.value.filter((v) => v !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLastValue = () => {
|
||||||
|
if (query.value) return
|
||||||
|
|
||||||
|
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||||
|
if (document.activeElement === emailRef) {
|
||||||
|
values.value.pop()
|
||||||
|
nextTick(() => {
|
||||||
|
if (values.value.length) {
|
||||||
|
emailRef = emails.value[emails.value.length - 1].$el
|
||||||
|
emailRef?.focus()
|
||||||
|
} else {
|
||||||
|
setFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emailRef?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFocus() {
|
||||||
|
search.value.el.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ setFocus })
|
||||||
|
</script>
|
||||||
130
frontend/src/components/Settings/InviteMemberPage.vue
Normal file
130
frontend/src/components/Settings/InviteMemberPage.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col gap-8 p-8">
|
||||||
|
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||||
|
{{ __('Send Invites To') }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<label class="block text-xs text-gray-600 mb-1.5">
|
||||||
|
{{ __('Invite by email') }}
|
||||||
|
</label>
|
||||||
|
<MultiValueInput
|
||||||
|
v-model="invitees"
|
||||||
|
:validate="validateEmail"
|
||||||
|
:error-message="
|
||||||
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
class="mt-4"
|
||||||
|
v-model="role"
|
||||||
|
variant="outline"
|
||||||
|
:label="__('Invite as')"
|
||||||
|
:options="[
|
||||||
|
{ label: __('Regular Access'), value: 'Sales User' },
|
||||||
|
{ label: __('Manager Access'), value: 'Sales Manager' },
|
||||||
|
]"
|
||||||
|
:description="description"
|
||||||
|
/>
|
||||||
|
<ErrorMessage class="mt-2" v-if="error" :message="error" />
|
||||||
|
<template v-if="pendingInvitations.data?.length && !invitees.length">
|
||||||
|
<div
|
||||||
|
class="mt-6 flex items-center justify-between py-4 text-base font-semibold"
|
||||||
|
>
|
||||||
|
<div>{{ __('Pending Invites') }}</div>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-1">
|
||||||
|
<li
|
||||||
|
class="flex items-center justify-between px-2 py-1 rounded-lg bg-gray-50"
|
||||||
|
v-for="user in pendingInvitations.data"
|
||||||
|
:key="user.name"
|
||||||
|
>
|
||||||
|
<div class="text-base">
|
||||||
|
<span class="text-gray-900">
|
||||||
|
{{ user.email }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600"> ({{ roleMap[user.role] }}) </span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Tooltip text="Delete Invitation">
|
||||||
|
<Button
|
||||||
|
icon="x"
|
||||||
|
variant="ghost"
|
||||||
|
:loading="
|
||||||
|
pendingInvitations.delete.loading &&
|
||||||
|
pendingInvitations.delete.params.name === user.name
|
||||||
|
"
|
||||||
|
@click="pendingInvitations.delete.submit(user.name)"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row-reverse">
|
||||||
|
<Button
|
||||||
|
:label="__('Send Invites')"
|
||||||
|
variant="solid"
|
||||||
|
@click="inviteByEmail.submit()"
|
||||||
|
:loading="inviteByEmail.loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import MultiValueInput from '@/components/Controls/MultiValueInput.vue'
|
||||||
|
import { validateEmail, convertArrayToString } from '@/utils'
|
||||||
|
import {
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
FormControl,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const invitees = ref([])
|
||||||
|
const role = ref('Sales User')
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const description = computed(() => {
|
||||||
|
return {
|
||||||
|
'Sales Manager':
|
||||||
|
'Can manage and invite new members, and create public & private views (reports).',
|
||||||
|
'Sales User':
|
||||||
|
'Can work with leads and deals and create private views (reports).',
|
||||||
|
}[role.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleMap = {
|
||||||
|
'Sales User': __('Regular Access'),
|
||||||
|
'Sales Manager': __('Manager Access'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteByEmail = createResource({
|
||||||
|
url: 'crm.api.invite_by_email',
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
emails: convertArrayToString(invitees.value),
|
||||||
|
role: role.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
invitees.value = []
|
||||||
|
role.value = 'Sales User'
|
||||||
|
error.value = null
|
||||||
|
pendingInvitations.reload()
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
error.value = error
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingInvitations = createListResource({
|
||||||
|
type: 'list',
|
||||||
|
doctype: 'CRM Invitation',
|
||||||
|
filters: { status: 'Pending' },
|
||||||
|
fields: ['name', 'email', 'role'],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -40,6 +40,7 @@
|
|||||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
|
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
|
||||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||||
import TwilioSettings from '@/components/Settings/TwilioSettings.vue'
|
import TwilioSettings from '@/components/Settings/TwilioSettings.vue'
|
||||||
@ -53,26 +54,31 @@ const show = defineModel()
|
|||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
let _tabs = [
|
let _tabs = [
|
||||||
{
|
{
|
||||||
label: 'Settings',
|
label: __('Settings'),
|
||||||
hideLabel: true,
|
hideLabel: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Profile',
|
label: __('Profile'),
|
||||||
icon: ContactsIcon,
|
icon: ContactsIcon,
|
||||||
component: markRaw(ProfileSettings),
|
component: markRaw(ProfileSettings),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: __('Invite Members'),
|
||||||
|
icon: 'user-plus',
|
||||||
|
component: markRaw(InviteMemberPage),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Integrations',
|
label: __('Integrations'),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Twilio',
|
label: __('Twilio'),
|
||||||
icon: PhoneIcon,
|
icon: PhoneIcon,
|
||||||
component: markRaw(TwilioSettings),
|
component: markRaw(TwilioSettings),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'WhatsApp',
|
label: __('WhatsApp'),
|
||||||
icon: WhatsAppIcon,
|
icon: WhatsAppIcon,
|
||||||
component: markRaw(WhatsAppSettings),
|
component: markRaw(WhatsAppSettings),
|
||||||
condition: () => isWhatsappInstalled.value,
|
condition: () => isWhatsappInstalled.value,
|
||||||
|
|||||||
@ -21,7 +21,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</slot>
|
</slot>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
|
<Tooltip
|
||||||
|
:text="label"
|
||||||
|
placement="right"
|
||||||
|
:disabled="isCollapsed"
|
||||||
|
:hoverDelay="1.5"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
|
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
@ -50,7 +55,7 @@ const route = useRoute()
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
icon: {
|
icon: {
|
||||||
type: Object,
|
type: [Object, String],
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@ -18,7 +18,9 @@
|
|||||||
@click="showAssignmentModal = true"
|
@click="showAssignmentModal = true"
|
||||||
/>
|
/>
|
||||||
</component>
|
</component>
|
||||||
<Dropdown :options="statusOptions('deal', updateField)">
|
<Dropdown
|
||||||
|
:options="statusOptions('deal', updateField, deal.data._customStatuses)"
|
||||||
|
>
|
||||||
<template #default="{ open }">
|
<template #default="{ open }">
|
||||||
<Button
|
<Button
|
||||||
:label="deal.data.status"
|
:label="deal.data.status"
|
||||||
@ -212,7 +214,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Dropdown :options="contactOptions(contact.name)">
|
<Dropdown :options="contactOptions(contact)">
|
||||||
<Button
|
<Button
|
||||||
icon="more-horizontal"
|
icon="more-horizontal"
|
||||||
class="text-gray-600"
|
class="text-gray-600"
|
||||||
@ -339,6 +341,7 @@ import {
|
|||||||
createToast,
|
createToast,
|
||||||
setupAssignees,
|
setupAssignees,
|
||||||
setupCustomActions,
|
setupCustomActions,
|
||||||
|
setupCustomStatuses,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
} from '@/utils'
|
} from '@/utils'
|
||||||
@ -380,8 +383,7 @@ const deal = createResource({
|
|||||||
params: { name: props.dealId },
|
params: { name: props.dealId },
|
||||||
cache: ['deal', props.dealId],
|
cache: ['deal', props.dealId],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setupAssignees(data)
|
let obj = {
|
||||||
setupCustomActions(data, {
|
|
||||||
doc: data,
|
doc: data,
|
||||||
$dialog,
|
$dialog,
|
||||||
router,
|
router,
|
||||||
@ -389,7 +391,10 @@ const deal = createResource({
|
|||||||
createToast,
|
createToast,
|
||||||
deleteDoc: deleteDeal,
|
deleteDoc: deleteDeal,
|
||||||
call,
|
call,
|
||||||
})
|
}
|
||||||
|
setupAssignees(data)
|
||||||
|
setupCustomStatuses(data, obj)
|
||||||
|
setupCustomActions(data, obj)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -567,9 +572,9 @@ const _contact = ref({})
|
|||||||
function contactOptions(contact) {
|
function contactOptions(contact) {
|
||||||
let options = [
|
let options = [
|
||||||
{
|
{
|
||||||
label: __('Delete'),
|
label: __('Remove'),
|
||||||
icon: 'trash-2',
|
icon: 'trash-2',
|
||||||
onClick: () => removeContact(contact),
|
onClick: () => removeContact(contact.name),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
@click="showAssignmentModal = true"
|
@click="showAssignmentModal = true"
|
||||||
/>
|
/>
|
||||||
</component>
|
</component>
|
||||||
<Dropdown :options="statusOptions('lead', updateField)">
|
<Dropdown :options="statusOptions('lead', updateField, lead.data._customStatuses)">
|
||||||
<template #default="{ open }">
|
<template #default="{ open }">
|
||||||
<Button
|
<Button
|
||||||
:label="lead.data.status"
|
:label="lead.data.status"
|
||||||
@ -308,6 +308,7 @@ import {
|
|||||||
createToast,
|
createToast,
|
||||||
setupAssignees,
|
setupAssignees,
|
||||||
setupCustomActions,
|
setupCustomActions,
|
||||||
|
setupCustomStatuses,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
} from '@/utils'
|
} from '@/utils'
|
||||||
@ -354,8 +355,7 @@ const lead = createResource({
|
|||||||
params: { name: props.leadId },
|
params: { name: props.leadId },
|
||||||
cache: ['lead', props.leadId],
|
cache: ['lead', props.leadId],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setupAssignees(data)
|
let obj = {
|
||||||
setupCustomActions(data, {
|
|
||||||
doc: data,
|
doc: data,
|
||||||
$dialog,
|
$dialog,
|
||||||
router,
|
router,
|
||||||
@ -363,7 +363,10 @@ const lead = createResource({
|
|||||||
createToast,
|
createToast,
|
||||||
deleteDoc: deleteLead,
|
deleteDoc: deleteLead,
|
||||||
call,
|
call,
|
||||||
})
|
}
|
||||||
|
setupAssignees(data)
|
||||||
|
setupCustomStatuses(data, obj)
|
||||||
|
setupCustomActions(data, obj)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
<div class="absolute right-0">
|
<div class="absolute right-0">
|
||||||
<Dropdown :options="statusOptions('deal', updateField)">
|
<Dropdown
|
||||||
|
:options="
|
||||||
|
statusOptions('deal', updateField, deal.data._customStatuses)
|
||||||
|
"
|
||||||
|
>
|
||||||
<template #default="{ open }">
|
<template #default="{ open }">
|
||||||
<Button
|
<Button
|
||||||
:label="deal.data.status"
|
:label="deal.data.status"
|
||||||
@ -274,7 +278,12 @@ import Section from '@/components/Section.vue'
|
|||||||
import SectionFields from '@/components/SectionFields.vue'
|
import SectionFields from '@/components/SectionFields.vue'
|
||||||
import SLASection from '@/components/SLASection.vue'
|
import SLASection from '@/components/SLASection.vue'
|
||||||
import CustomActions from '@/components/CustomActions.vue'
|
import CustomActions from '@/components/CustomActions.vue'
|
||||||
import { createToast, setupAssignees, setupCustomActions } from '@/utils'
|
import {
|
||||||
|
createToast,
|
||||||
|
setupAssignees,
|
||||||
|
setupCustomActions,
|
||||||
|
setupCustomStatuses,
|
||||||
|
} from '@/utils'
|
||||||
import { getView } from '@/utils/view'
|
import { getView } from '@/utils/view'
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { organizationsStore } from '@/stores/organizations'
|
import { organizationsStore } from '@/stores/organizations'
|
||||||
@ -313,8 +322,7 @@ const deal = createResource({
|
|||||||
params: { name: props.dealId },
|
params: { name: props.dealId },
|
||||||
cache: ['deal', props.dealId],
|
cache: ['deal', props.dealId],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setupAssignees(data)
|
let obj = {
|
||||||
setupCustomActions(data, {
|
|
||||||
doc: data,
|
doc: data,
|
||||||
$dialog,
|
$dialog,
|
||||||
router,
|
router,
|
||||||
@ -322,7 +330,10 @@ const deal = createResource({
|
|||||||
createToast,
|
createToast,
|
||||||
deleteDoc: deleteDeal,
|
deleteDoc: deleteDeal,
|
||||||
call,
|
call,
|
||||||
})
|
}
|
||||||
|
setupAssignees(data)
|
||||||
|
setupCustomStatuses(data, obj)
|
||||||
|
setupCustomActions(data, obj)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
<div class="absolute right-0">
|
<div class="absolute right-0">
|
||||||
<Dropdown :options="statusOptions('lead', updateField)">
|
<Dropdown
|
||||||
|
:options="
|
||||||
|
statusOptions('lead', updateField, lead.data._customStatuses)
|
||||||
|
"
|
||||||
|
>
|
||||||
<template #default="{ open }">
|
<template #default="{ open }">
|
||||||
<Button
|
<Button
|
||||||
:label="lead.data.status"
|
:label="lead.data.status"
|
||||||
@ -195,7 +199,12 @@ import Section from '@/components/Section.vue'
|
|||||||
import SectionFields from '@/components/SectionFields.vue'
|
import SectionFields from '@/components/SectionFields.vue'
|
||||||
import SLASection from '@/components/SLASection.vue'
|
import SLASection from '@/components/SLASection.vue'
|
||||||
import CustomActions from '@/components/CustomActions.vue'
|
import CustomActions from '@/components/CustomActions.vue'
|
||||||
import { createToast, setupAssignees, setupCustomActions } from '@/utils'
|
import {
|
||||||
|
createToast,
|
||||||
|
setupAssignees,
|
||||||
|
setupCustomActions,
|
||||||
|
setupCustomStatuses,
|
||||||
|
} from '@/utils'
|
||||||
import { getView } from '@/utils/view'
|
import { getView } from '@/utils/view'
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { contactsStore } from '@/stores/contacts'
|
import { contactsStore } from '@/stores/contacts'
|
||||||
@ -236,8 +245,7 @@ const lead = createResource({
|
|||||||
params: { name: props.leadId },
|
params: { name: props.leadId },
|
||||||
cache: ['lead', props.leadId],
|
cache: ['lead', props.leadId],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setupAssignees(data)
|
let obj = {
|
||||||
setupCustomActions(data, {
|
|
||||||
doc: data,
|
doc: data,
|
||||||
$dialog,
|
$dialog,
|
||||||
router,
|
router,
|
||||||
@ -245,7 +253,10 @@ const lead = createResource({
|
|||||||
createToast,
|
createToast,
|
||||||
deleteDoc: deleteLead,
|
deleteDoc: deleteLead,
|
||||||
call,
|
call,
|
||||||
})
|
}
|
||||||
|
setupAssignees(data)
|
||||||
|
setupCustomStatuses(data, obj)
|
||||||
|
setupCustomActions(data, obj)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -64,9 +64,9 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
|||||||
} else if (['gray', 'green'].includes(color)) {
|
} else if (['gray', 'green'].includes(color)) {
|
||||||
textColor = `!text-${color}-700`
|
textColor = `!text-${color}-700`
|
||||||
}
|
}
|
||||||
|
|
||||||
let bgColor = `!bg-${color}-100 hover:!bg-${color}-200 active:!bg-${color}-300`
|
let bgColor = `!bg-${color}-100 hover:!bg-${color}-200 active:!bg-${color}-300`
|
||||||
|
|
||||||
return [textColor, onlyIcon ? '' : bgColor]
|
return [textColor, onlyIcon ? '' : bgColor]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,9 +91,17 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
|||||||
return communicationStatuses[name]
|
return communicationStatuses[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusOptions(doctype, action) {
|
function statusOptions(doctype, action, statuses = []) {
|
||||||
let statusesByName =
|
let statusesByName =
|
||||||
doctype == 'deal' ? dealStatusesByName : leadStatusesByName
|
doctype == 'deal' ? dealStatusesByName : leadStatusesByName
|
||||||
|
|
||||||
|
if (statuses.length) {
|
||||||
|
statusesByName = statuses.reduce((acc, status) => {
|
||||||
|
acc[status] = statusesByName[status]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
let options = []
|
let options = []
|
||||||
for (const status in statusesByName) {
|
for (const status in statusesByName) {
|
||||||
options.push({
|
options.push({
|
||||||
|
|||||||
@ -131,6 +131,27 @@ function getActionsFromScript(script, obj) {
|
|||||||
return formScript?.actions || []
|
return formScript?.actions || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusFromScript(script, obj) {
|
||||||
|
let scriptFn = new Function(script + '\nreturn setupForm')()
|
||||||
|
let formScript = scriptFn(obj)
|
||||||
|
return formScript?.statuses || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupCustomStatuses(data, obj) {
|
||||||
|
if (!data._form_script) return []
|
||||||
|
|
||||||
|
let statuses = []
|
||||||
|
if (Array.isArray(data._form_script)) {
|
||||||
|
data._form_script.forEach((script) => {
|
||||||
|
statuses = statuses.concat(getStatusFromScript(script, obj))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
statuses = getStatusFromScript(data._form_script, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
data._customStatuses = statuses
|
||||||
|
}
|
||||||
|
|
||||||
export function setupCustomActions(data, obj) {
|
export function setupCustomActions(data, obj) {
|
||||||
if (!data._form_script) return []
|
if (!data._form_script) return []
|
||||||
|
|
||||||
@ -216,3 +237,7 @@ export function isEmoji(str) {
|
|||||||
export function isTouchScreenDevice() {
|
export function isTouchScreenDevice() {
|
||||||
return "ontouchstart" in document.documentElement;
|
return "ontouchstart" in document.documentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function convertArrayToString(array) {
|
||||||
|
return array.map((item) => item).join(',')
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user