Merge pull request #327 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2024-09-03 23:43:52 +05:30 committed by GitHub
commit 46daa6f477
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1145 additions and 337 deletions

View File

@ -1,7 +1,7 @@
from bs4 import BeautifulSoup
import frappe
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
@ -47,6 +47,7 @@ def get_user_signature():
content = f'<br><p class="signature">{signature}</p>'
return content
@frappe.whitelist()
def get_posthog_settings():
return {
@ -54,4 +55,55 @@ def get_posthog_settings():
"posthog_host": frappe.conf.get(POSTHOG_HOST_FIELD),
"enable_telemetry": frappe.get_system_settings("enable_telemetry"),
"telemetry_site_age": frappe.utils.telemetry.site_age(),
}
}
def check_app_permission():
if frappe.session.user == "Administrator":
return True
roles = frappe.get_roles()
if any(role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles):
return True
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)

View 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");
});
}
},
});

View 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": []
}

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

View 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

View File

@ -18,7 +18,7 @@ add_to_apps_screen = [
"logo": "/assets/crm/manifest/apple-icon-180.png",
"title": "CRM",
"route": "/crm",
# "has_permission": "crm.api.permission.has_app_permission"
"has_permission": "crm.api.check_app_permission",
}
]

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

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

View File

@ -0,0 +1,21 @@
<template>
<div v-if="isEmoji(icon)" v-bind="$attrs">
{{ icon }}
</div>
<FeatherIcon
v-else-if="typeof icon == 'string'"
:name="icon"
v-bind="$attrs"
/>
<component v-else :is="icon" v-bind="$attrs" />
</template>
<script setup>
import { isEmoji } from '@/utils'
const props = defineProps({
icon: {
type: [String, Object],
required: true,
},
})
</script>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.64645 1.89645C1.84171 1.70118 2.15829 1.70118 2.35355 1.89645L4.35355 3.89645C4.54882 4.09171 4.54882 4.40829 4.35355 4.60355L2.35355 6.60355C2.15829 6.79882 1.84171 6.79882 1.64645 6.60355C1.45118 6.40829 1.45118 6.09171 1.64645 5.89645L3.29289 4.25L1.64645 2.60355C1.45118 2.40829 1.45118 2.09171 1.64645 1.89645ZM5.5 3.2002C5.5 2.92405 5.72386 2.7002 6 2.7002H14C14.2761 2.7002 14.5 2.92405 14.5 3.2002C14.5 3.47634 14.2761 3.7002 14 3.7002H6C5.72386 3.7002 5.5 3.47634 5.5 3.2002ZM5.5 8.00024C5.5 7.7241 5.72386 7.50024 6 7.50024H14C14.2761 7.50024 14.5 7.7241 14.5 8.00024C14.5 8.27639 14.2761 8.50024 14 8.50024H6C5.72386 8.50024 5.5 8.27639 5.5 8.00024ZM6 12.3003C5.72386 12.3003 5.5 12.5242 5.5 12.8003C5.5 13.0764 5.72386 13.3003 6 13.3003H14C14.2761 13.3003 14.5 13.0764 14.5 12.8003C14.5 12.5242 14.2761 12.3003 14 12.3003H6ZM2.35355 9.39645C2.15829 9.20118 1.84171 9.20118 1.64645 9.39645C1.45118 9.59171 1.45118 9.90829 1.64645 10.1036L3.29289 11.75L1.64645 13.3964C1.45118 13.5917 1.45118 13.9083 1.64645 14.1036C1.84171 14.2988 2.15829 14.2988 2.35355 14.1036L4.35355 12.1036C4.54882 11.9083 4.54882 11.5917 4.35355 11.3964L2.35355 9.39645Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -1,18 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-kanban"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6 5v11" />
<path d="M12 5v6" />
<path d="M18 5v14" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.69971 3.00098C3.69971 2.72483 3.47585 2.50098 3.19971 2.50098C2.92356 2.50098 2.69971 2.72483 2.69971 3.00098V11.001C2.69971 11.2771 2.92356 11.501 3.19971 11.501C3.47585 11.501 3.69971 11.2771 3.69971 11.001V3.00098ZM8 2.50098C8.27614 2.50098 8.5 2.72483 8.5 3.00098V13.001C8.5 13.2771 8.27614 13.501 8 13.501C7.72386 13.501 7.5 13.2771 7.5 13.001V3.00098C7.5 2.72483 7.72386 2.50098 8 2.50098ZM12.7998 2.50098C13.0759 2.50098 13.2998 2.72483 13.2998 3.00098V8.00098C13.2998 8.27712 13.0759 8.50098 12.7998 8.50098C12.5237 8.50098 12.2998 8.27712 12.2998 8.00098V3.00098C12.2998 2.72483 12.5237 2.50098 12.7998 2.50098Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.5 3.2002C2.5 2.92405 2.72386 2.7002 3 2.7002H13C13.2761 2.7002 13.5 2.92405 13.5 3.2002C13.5 3.47634 13.2761 3.7002 13 3.7002H3C2.72386 3.7002 2.5 3.47634 2.5 3.2002ZM2.5 8.00024C2.5 7.7241 2.72386 7.50024 3 7.50024H13C13.2761 7.50024 13.5 7.7241 13.5 8.00024C13.5 8.27639 13.2761 8.50024 13 8.50024H3C2.72386 8.50024 2.5 8.27639 2.5 8.00024ZM3 12.3003C2.72386 12.3003 2.5 12.5242 2.5 12.8003C2.5 13.0764 2.72386 13.3003 3 13.3003H13C13.2761 13.3003 13.5 13.0764 13.5 12.8003C13.5 12.5242 13.2761 12.3003 13 12.3003H3Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -1,7 +1,7 @@
<template>
<Teleport to="#app-header" v-if="showHeader">
<slot>
<header class="flex h-12 items-center justify-between py-2.5 pl-5">
<header class="flex h-10.5 items-center justify-between py-[7px] pl-5">
<div class="flex items-center gap-2">
<slot name="left-header" />
</div>

View File

@ -7,6 +7,7 @@
getRowRoute: (row) => ({
name: 'Contact',
params: { contactId: row.name },
query: { view: route.query.view, viewType: route.params.viewType },
}),
selectable: options.selectable,
showTooltip: options.showTooltip,
@ -174,6 +175,7 @@ import {
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
rows: {
@ -205,6 +207,8 @@ const emit = defineEmits([
'likeDoc',
])
const route = useRoute()
const pageLengthCount = defineModel()
const list = defineModel('list')
@ -230,7 +234,7 @@ const listBulkActionsRef = ref(null)
defineExpose({
customListActions: computed(
() => listBulkActionsRef.value?.customListActions
() => listBulkActionsRef.value?.customListActions,
),
})
</script>

View File

@ -4,14 +4,21 @@
:columns="columns"
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }),
getRowRoute: (row) => ({
name: 'Deal',
params: { dealId: row.name },
query: { view: route.query.view, viewType: route.params.viewType },
}),
selectable: options.selectable,
showTooltip: options.showTooltip,
resizeColumn: options.resizeColumn,
}"
row-key="name"
>
<ListHeader class="sm:mx-5 mx-3" @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeader
class="sm:mx-5 mx-3"
@columnWidthUpdated="emit('columnWidthUpdated')"
>
<ListHeaderItem
v-for="column in columns"
:key="column.key"
@ -204,6 +211,7 @@ import {
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
rows: {
@ -235,6 +243,8 @@ const emit = defineEmits([
'likeDoc',
])
const route = useRoute()
const pageLengthCount = defineModel()
const list = defineModel('list')
@ -260,7 +270,7 @@ const listBulkActionsRef = ref(null)
defineExpose({
customListActions: computed(
() => listBulkActionsRef.value?.customListActions
() => listBulkActionsRef.value?.customListActions,
),
})
</script>

View File

@ -4,14 +4,21 @@
:columns="columns"
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }),
getRowRoute: (row) => ({
name: 'Lead',
params: { leadId: row.name },
query: { view: route.query.view, viewType: route.params.viewType },
}),
selectable: options.selectable,
showTooltip: options.showTooltip,
resizeColumn: options.resizeColumn,
}"
row-key="name"
>
<ListHeader class="sm:mx-5 mx-3" @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeader
class="sm:mx-5 mx-3"
@columnWidthUpdated="emit('columnWidthUpdated')"
>
<ListHeaderItem
v-for="column in columns"
:key="column.key"
@ -217,6 +224,7 @@ import {
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
rows: {
@ -248,6 +256,8 @@ const emit = defineEmits([
'likeDoc',
])
const route = useRoute()
const pageLengthCount = defineModel()
const list = defineModel('list')
@ -273,7 +283,7 @@ const listBulkActionsRef = ref(null)
defineExpose({
customListActions: computed(
() => listBulkActionsRef.value?.customListActions
() => listBulkActionsRef.value?.customListActions,
),
})
</script>

View File

@ -6,6 +6,7 @@
getRowRoute: (row) => ({
name: 'Organization',
params: { organizationId: row.name },
query: { view: route.query.view, viewType: route.params.viewType },
}),
selectable: options.selectable,
showTooltip: options.showTooltip,
@ -156,6 +157,7 @@ import {
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
rows: {
@ -187,6 +189,8 @@ const emit = defineEmits([
'likeDoc',
])
const route = useRoute()
const pageLengthCount = defineModel()
const list = defineModel('list')

View File

@ -108,9 +108,9 @@ watch(show, (value) => {
duplicateMode.value = false
nextTick(() => {
_view.value = { ...view.value }
if (_view.value.name) {
if (_view.value.mode === 'edit') {
editMode.value = true
} else if (_view.value.label) {
} else if (_view.value.mode === 'duplicate') {
duplicateMode.value = true
}
})

View File

@ -0,0 +1,126 @@
<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">
<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-4 flex items-center justify-between border-b py-2 text-base text-gray-600"
>
<div class="w-4/5">{{ __('Pending Invites') }}</div>
</div>
<ul class="divide-y overflow-auto">
<li
class="flex items-center justify-between py-2"
v-for="user in pendingInvitations.data"
:key="user.name"
>
<div class="w-4/5 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"
: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>

View File

@ -40,6 +40,7 @@
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import TwilioSettings from '@/components/Settings/TwilioSettings.vue'
@ -53,26 +54,31 @@ const show = defineModel()
const tabs = computed(() => {
let _tabs = [
{
label: 'Settings',
label: __('Settings'),
hideLabel: true,
items: [
{
label: 'Profile',
label: __('Profile'),
icon: ContactsIcon,
component: markRaw(ProfileSettings),
},
{
label: __('Invite Members'),
icon: 'user-plus',
component: markRaw(InviteMemberPage),
},
],
},
{
label: 'Integrations',
label: __('Integrations'),
items: [
{
label: 'Twilio',
label: __('Twilio'),
icon: PhoneIcon,
component: markRaw(TwilioSettings),
},
{
label: 'WhatsApp',
label: __('WhatsApp'),
icon: WhatsAppIcon,
component: markRaw(WhatsAppSettings),
condition: () => isWhatsappInstalled.value,

View File

@ -21,7 +21,12 @@
</span>
</slot>
</Tooltip>
<Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
<Tooltip
:text="label"
placement="right"
:disabled="isCollapsed"
:hoverDelay="1.5"
>
<span
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
:class="
@ -50,7 +55,7 @@ const route = useRoute()
const props = defineProps({
icon: {
type: Object,
type: [Object, String],
},
label: {
type: String,

View File

@ -0,0 +1,97 @@
<template>
<div class="flex items-center">
<router-link
:to="{ name: routeName }"
class="px-0.5 py-1 text-lg font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-600 hover:text-gray-700"
>
{{ __(routeName) }}
</router-link>
<span class="mx-0.5 text-base text-gray-500" aria-hidden="true"> / </span>
<Dropdown v-if="viewControls" :options="viewControls.viewsDropdownOptions">
<template #default="{ open }">
<Button
variant="ghost"
class="text-lg font-medium text-nowrap"
:label="__(viewControls.currentView.label)"
>
<template #prefix>
<Icon :icon="viewControls.currentView.icon" class="h-4" />
</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-800"
/>
</template>
</Button>
</template>
<template #item="{ item, active }">
<button
:class="[
active ? 'bg-gray-100' : 'text-gray-800',
'group flex gap-4 h-7 w-full justify-between items-center rounded px-2 text-base',
]"
@click="item.onClick"
>
<div class="flex items-center">
<FeatherIcon
v-if="item.icon && typeof item.icon === 'string'"
:name="item.icon"
class="mr-2 h-4 w-4 flex-shrink-0 text-gray-700"
aria-hidden="true"
/>
<component
class="mr-2 h-4 w-4 flex-shrink-0 text-gray-700"
v-else-if="item.icon"
:is="item.icon"
/>
<span class="whitespace-nowrap">
{{ item.label }}
</span>
</div>
<div
v-if="item.name"
class="flex flex-row-reverse gap-2 items-center min-w-11"
>
<Dropdown
:class="active ? 'block' : 'hidden'"
placement="right-start"
:options="viewControls.viewActions(item)"
>
<template #default="{ togglePopover }">
<Button
variant="ghost"
class="!size-5"
icon="more-horizontal"
@click.stop="togglePopover()"
/>
</template>
</Dropdown>
<FeatherIcon
v-if="isCurrentView(item)"
name="check"
class="size-4 text-gray-700"
/>
</div>
</button>
</template>
</Dropdown>
</div>
</template>
<script setup>
import Icon from '@/components/Icon.vue'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
const props = defineProps({
routeName: {
type: String,
required: true,
},
})
const viewControls = defineModel()
const isCurrentView = (item) => {
return item.name === viewControls.value.currentView.name
}
</script>

View File

@ -3,43 +3,6 @@
v-if="isMobileView"
class="flex flex-col justify-between gap-2 sm:px-5 px-3 py-4"
>
<div class="flex items-center justify-between gap-2 overflow-x-auto">
<div class="flex gap-2">
<Dropdown :options="viewsDropdownOptions">
<template #default="{ open }">
<Button :label="__(currentView.label)">
<template #prefix>
<div v-if="isEmoji(currentView.icon)">
{{ currentView.icon }}
</div>
<FeatherIcon
v-else-if="typeof currentView.icon == 'string'"
:name="currentView.icon"
class="h-4"
/>
<component v-else :is="currentView.icon" class="h-4" />
</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
<Dropdown :options="viewActions">
<template #default>
<Button icon="more-horizontal" />
</template>
</Dropdown>
</div>
<Button :label="__('Refresh')" @click="reload()" :loading="isLoading">
<template #icon>
<RefreshIcon class="h-4 w-4" />
</template>
</Button>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 overflow-x-auto">
<div class="flex gap-2">
@ -59,6 +22,11 @@
</div>
<div class="flex gap-2">
<Button :label="__('Refresh')" @click="reload()" :loading="isLoading">
<template #icon>
<RefreshIcon class="h-4 w-4" />
</template>
</Button>
<SortBy
v-if="route.params.viewType !== 'kanban'"
v-model="list"
@ -91,37 +59,8 @@
</div>
</div>
<div v-else class="flex items-center justify-between gap-2 px-5 py-4">
<div class="flex items-center gap-2">
<Dropdown :options="viewsDropdownOptions">
<template #default="{ open }">
<Button :label="__(currentView.label)">
<template #prefix>
<div v-if="isEmoji(currentView.icon)">{{ currentView.icon }}</div>
<FeatherIcon
v-else-if="typeof currentView.icon == 'string'"
:name="currentView.icon"
class="h-4"
/>
<component v-else :is="currentView.icon" class="h-4" />
</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
<Dropdown :options="viewActions">
<template #default>
<Button icon="more-horizontal" />
</template>
</Dropdown>
</div>
<div class="-mr-2 h-[70%] border-l" />
<FadedScrollableDiv
class="flex flex-1 items-center overflow-x-auto px-1"
class="flex flex-1 items-center overflow-x-auto -ml-1"
orientation="horizontal"
>
<div
@ -223,6 +162,7 @@
afterUpdate: () => {
viewUpdated = false
reloadView()
list.reload()
},
}"
/>
@ -268,8 +208,9 @@
</Dialog>
</template>
<script setup>
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import ListIcon from '@/components/Icons/ListIcon.vue'
import KanbanIcon from '@/components/Icons/KanbanIcon.vue'
import GroupByIcon from '@/components/Icons/GroupByIcon.vue'
import QuickFilterField from '@/components/QuickFilterField.vue'
import RefreshIcon from '@/components/Icons/RefreshIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
@ -340,15 +281,18 @@ function getViewType() {
let viewType = route.params.viewType || 'list'
let types = {
list: {
label: __('List View'),
icon: 'list',
name: 'list',
label: __('List'),
icon: markRaw(ListIcon),
},
group_by: {
label: __('Group By View'),
icon: markRaw(DetailsIcon),
name: 'group_by',
label: __('Group By'),
icon: markRaw(GroupByIcon),
},
kanban: {
label: __('Kanban View'),
name: 'kanban',
label: __('Kanban'),
icon: markRaw(KanbanIcon),
},
}
@ -359,6 +303,7 @@ function getViewType() {
const currentView = computed(() => {
let _view = getView(route.query.view, route.params.viewType, props.doctype)
return {
name: _view?.name || getViewType().name,
label:
_view?.label || props.options?.defaultViewName || getViewType().label,
icon: _view?.icon || getViewType().icon,
@ -531,27 +476,19 @@ let allowedViews = props.options.allowedViews || ['list']
if (allowedViews.includes('list')) {
defaultViews.push({
label: __(props.options?.defaultViewName) || __('List View'),
icon: 'list',
name: 'list',
label: __(props.options?.defaultViewName) || __('List'),
icon: markRaw(ListIcon),
onClick() {
viewUpdated.value = false
router.push({ name: route.name })
},
})
}
if (allowedViews.includes('group_by')) {
defaultViews.push({
label: __(props.options?.defaultViewName) || __('Group By View'),
icon: markRaw(DetailsIcon),
onClick() {
viewUpdated.value = false
router.push({ name: route.name, params: { viewType: 'group_by' } })
},
})
}
if (allowedViews.includes('kanban')) {
defaultViews.push({
label: __(props.options?.defaultViewName) || __('Kanban View'),
name: 'kanban',
label: __(props.options?.defaultViewName) || __('Kanban'),
icon: markRaw(KanbanIcon),
onClick() {
viewUpdated.value = false
@ -559,14 +496,27 @@ if (allowedViews.includes('kanban')) {
},
})
}
if (allowedViews.includes('group_by')) {
defaultViews.push({
name: 'group_by',
label: __(props.options?.defaultViewName) || __('Group By'),
icon: markRaw(GroupByIcon),
onClick() {
viewUpdated.value = false
router.push({ name: route.name, params: { viewType: 'group_by' } })
},
})
}
function getIcon(icon, type) {
if (isEmoji(icon)) {
return h('div', icon)
} else if (!icon && type === 'group_by') {
return markRaw(DetailsIcon)
return markRaw(GroupByIcon)
} else if (!icon && type === 'kanban') {
return markRaw(KanbanIcon)
}
return icon || 'list'
return icon || markRaw(ListIcon)
}
const viewsDropdownOptions = computed(() => {
@ -580,6 +530,7 @@ const viewsDropdownOptions = computed(() => {
if (list.value?.data?.views) {
list.value.data.views.forEach((view) => {
view.name = view.name
view.label = __(view.label)
view.type = view.type || 'list'
view.icon = getIcon(view.icon, view.type)
@ -602,17 +553,16 @@ const viewsDropdownOptions = computed(() => {
)
let pinnedViews = list.value.data.views.filter((v) => v.pinned)
publicViews.length &&
_views.push({
group: __('Public Views'),
items: publicViews,
})
savedViews.length &&
_views.push({
group: __('Saved Views'),
items: savedViews,
})
publicViews.length &&
_views.push({
group: __('Public Views'),
items: publicViews,
})
pinnedViews.length &&
_views.push({
group: __('Pinned Views'),
@ -620,6 +570,18 @@ const viewsDropdownOptions = computed(() => {
})
}
_views.push({
group: __('Actions'),
hideLabel: true,
items: [
{
label: __('Create View'),
icon: 'plus',
onClick: () => createView(),
},
],
})
return _views
})
@ -912,7 +874,10 @@ function updatePageLength(value, loadMore = false) {
}
// View Actions
const viewActions = computed(() => {
const viewActions = (view) => {
let isDefault = typeof view.name === 'string'
let _view = getView(view.name)
let actions = [
{
group: __('Default Views'),
@ -921,37 +886,36 @@ const viewActions = computed(() => {
{
label: __('Duplicate'),
icon: () => h(DuplicateIcon, { class: 'h-4 w-4' }),
onClick: () => duplicateView(),
onClick: () => duplicateView(_view),
},
],
},
]
if (route.query.view && (!view.value.public || isManager())) {
if (!isDefault && (!_view.public || isManager())) {
actions[0].items.push({
label: __('Edit'),
icon: () => h(EditIcon, { class: 'h-4 w-4' }),
onClick: () => editView(),
onClick: () => editView(_view),
})
if (!view.value.public) {
if (!_view.public) {
actions[0].items.push({
label: view.value.pinned ? __('Unpin View') : __('Pin View'),
icon: () =>
h(view.value.pinned ? UnpinIcon : PinIcon, { class: 'h-4 w-4' }),
onClick: () => pinView(),
label: _view.pinned ? __('Unpin View') : __('Pin View'),
icon: () => h(_view.pinned ? UnpinIcon : PinIcon, { class: 'h-4 w-4' }),
onClick: () => pinView(_view),
})
}
if (isManager()) {
actions[0].items.push({
label: view.value.public ? __('Make Private') : __('Make Public'),
label: _view.public ? __('Make Private') : __('Make Public'),
icon: () =>
h(FeatherIcon, {
name: view.value.public ? 'lock' : 'unlock',
name: _view.public ? 'lock' : 'unlock',
class: 'h-4 w-4',
}),
onClick: () => publicView(),
onClick: () => publicView(_view),
})
}
@ -965,14 +929,16 @@ const viewActions = computed(() => {
onClick: () =>
$dialog({
title: __('Delete View'),
message: __('Are you sure you want to delete this view?'),
message: __('Are you sure you want to delete "{0}" view?', [
_view.label,
]),
variant: 'danger',
actions: [
{
label: __('Delete'),
variant: 'solid',
theme: 'red',
onClick: (close) => deleteView(close),
onClick: (close) => deleteView(_view, close),
},
],
}),
@ -981,56 +947,61 @@ const viewActions = computed(() => {
})
}
return actions
})
}
const viewModalObj = ref({})
function duplicateView() {
let label =
__(
getView(route.query.view, route.params.viewType, props.doctype)?.label,
) || getViewType().label
function createView() {
view.value.name = ''
view.value.label = label + __(' (New)')
view.value.label = ''
view.value.icon = ''
viewModalObj.value = view.value
viewModalObj.value.mode = 'create'
showViewModal.value = true
}
function editView() {
let cView = getView(route.query.view, route.params.viewType, props.doctype)
view.value.name = route.query.view
view.value.label = __(cView?.label) || getViewType().label
view.value.icon = cView?.icon || ''
viewModalObj.value = view.value
function duplicateView(v) {
v.label = v.label + __(' (New)')
viewModalObj.value = v
viewModalObj.value.mode = 'duplicate'
showViewModal.value = true
}
function publicView() {
function editView(v) {
viewModalObj.value = v
viewModalObj.value.mode = 'edit'
showViewModal.value = true
}
function publicView(v) {
call('crm.fcrm.doctype.crm_view_settings.crm_view_settings.public', {
name: route.query.view,
value: !view.value.public,
name: v.name,
value: !v.public,
}).then(() => {
view.value.public = !view.value.public
v.public = !v.public
reloadView()
list.value.reload()
})
}
function pinView() {
function pinView(v) {
call('crm.fcrm.doctype.crm_view_settings.crm_view_settings.pin', {
name: route.query.view,
value: !view.value.pinned,
name: v.name,
value: !v.pinned,
}).then(() => {
view.value.pinned = !view.value.pinned
v.pinned = !v.pinned
reloadView()
list.value.reload()
})
}
function deleteView(close) {
function deleteView(v, close) {
call('crm.fcrm.doctype.crm_view_settings.crm_view_settings.delete', {
name: route.query.view,
name: v.name,
}).then(() => {
router.push({ name: route.name })
reloadView()
list.value.reload()
})
close()
}
@ -1059,6 +1030,7 @@ function saveView() {
load_default_columns: view.value.load_default_columns,
}
viewModalObj.value = view.value
viewModalObj.value.mode = 'edit'
showViewModal.value = true
}
@ -1119,6 +1091,9 @@ defineExpose({
likeDoc,
updateKanbanSettings,
loadMoreKanban,
viewActions,
viewsDropdownOptions,
currentView,
})
// Watchers

View File

@ -5,9 +5,9 @@
:show="open"
:placement="popoverPlacement"
>
<template #target>
<MenuButton as="div">
<slot v-if="$slots.default" v-bind="{ open }" />
<template #target="{ togglePopover }">
<MenuButton as="template">
<slot v-if="$slots.default" v-bind="{ open, togglePopover }" />
<Button v-else :active="open" v-bind="button">
{{ button ? button?.label || null : 'Options' }}
</Button>
@ -17,13 +17,18 @@
<template #body>
<div
class="rounded-lg bg-white shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
:class="{
'mt-2': ['bottom', 'left', 'right'].includes(placement),
'ml-2': placement == 'right-start',
}"
>
<MenuItems
class="mt-2 min-w-40 divide-y divide-gray-100"
class="min-w-40 divide-y divide-gray-100"
:class="{
'left-0 origin-top-left': placement == 'left',
'right-0 origin-top-right': placement == 'right',
'inset-x-0 origin-top': placement == 'center',
'mt-0 origin-top-right': placement == 'right-start',
}"
>
<div v-for="group in groups" :key="group.key" class="p-1.5">
@ -38,34 +43,36 @@
:key="item.label"
v-slot="{ active }"
>
<component
v-if="item.component"
:is="item.component"
:active="active"
/>
<button
v-else
:class="[
active ? 'bg-gray-100' : 'text-gray-800',
'group flex h-7 w-full items-center rounded px-2 text-base',
]"
@click="item.onClick"
>
<FeatherIcon
v-if="item.icon && typeof item.icon === 'string'"
:name="item.icon"
class="mr-2 h-4 w-4 flex-shrink-0 text-gray-700"
aria-hidden="true"
/>
<slot name="item" v-bind="{ item, active }">
<component
class="mr-2 h-4 w-4 flex-shrink-0 text-gray-700"
v-else-if="item.icon"
:is="item.icon"
v-if="item.component"
:is="item.component"
:active="active"
/>
<span class="whitespace-nowrap">
{{ item.label }}
</span>
</button>
<button
v-else
:class="[
active ? 'bg-gray-100' : 'text-gray-800',
'group flex h-7 w-full items-center rounded px-2 text-base',
]"
@click="item.onClick"
>
<FeatherIcon
v-if="item.icon && typeof item.icon === 'string'"
:name="item.icon"
class="mr-2 h-4 w-4 flex-shrink-0 text-gray-700"
aria-hidden="true"
/>
<component
class="mr-2 h-4 w-4 flex-shrink-0 text-gray-700"
v-else-if="item.icon"
:is="item.icon"
/>
<span class="whitespace-nowrap">
{{ item.label }}
</span>
</button>
</slot>
</MenuItem>
</div>
</MenuItems>
@ -130,6 +137,7 @@ const popoverPlacement = computed(() => {
if (props.placement === 'left') return 'bottom-start'
if (props.placement === 'right') return 'bottom-end'
if (props.placement === 'center') return 'bottom-center'
if (props.placement === 'right-start') return 'right-start'
return 'bottom'
})
@ -140,6 +148,7 @@ function normalizeDropdownItem(option) {
}
return {
name: option.name,
label: option.label,
icon: option.icon,
group: option.group,

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Call Logs" />
</template>
<template #right-header>
<CustomActions
@ -54,6 +54,7 @@
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import CustomActions from '@/components/CustomActions.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
@ -61,11 +62,8 @@ import ViewControls from '@/components/ViewControls.vue'
import CallLogsListView from '@/components/ListViews/CallLogsListView.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue'
import { getCallLogDetail } from '@/utils/callLog'
import { Breadcrumbs } from 'frappe-ui'
import { computed, ref } from 'vue'
const breadcrumbs = [{ label: __('Call Logs'), route: { name: 'Call Logs' } }]
const callLogsListView = ref(null)
// callLogs data is loaded in the ViewControls component

View File

@ -1,7 +1,11 @@
<template>
<LayoutHeader v-if="contact.data">
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
</template>
</LayoutHeader>
<div v-if="contact.data" class="flex h-full flex-col overflow-hidden">
@ -216,16 +220,7 @@
</template>
<script setup>
import {
Breadcrumbs,
Avatar,
FileUploader,
Tooltip,
Tabs,
call,
createResource,
usePageMeta,
} from 'frappe-ui'
import Icon from '@/components/Icon.vue'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
@ -242,13 +237,24 @@ import {
timeAgo,
formatNumberIntoCurrency,
} from '@/utils'
import { getView } from '@/utils/view'
import { globalStore } from '@/stores/global.js'
import { usersStore } from '@/stores/users.js'
import { organizationsStore } from '@/stores/organizations.js'
import { statusesStore } from '@/stores/statuses'
import { callEnabled } from '@/composables/settings'
import {
Breadcrumbs,
Avatar,
FileUploader,
Tooltip,
Tabs,
call,
createResource,
usePageMeta,
} from 'frappe-ui'
import { ref, computed, h } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
const { $dialog, makeCall } = globalStore()
@ -263,6 +269,7 @@ const props = defineProps({
},
})
const route = useRoute()
const router = useRouter()
const showContactModal = ref(false)
@ -287,6 +294,22 @@ const contact = createResource({
const breadcrumbs = computed(() => {
let items = [{ label: __('Contacts'), route: { name: 'Contacts' } }]
if (route.query.view || route.query.viewType) {
let view = getView(route.query.view, route.query.viewType, 'Contact')
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Contacts',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: contact.data?.full_name,
route: { name: 'Contact', params: { contactId: props.contactId } },
@ -300,7 +323,6 @@ usePageMeta(() => {
}
})
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
if (!['png', 'jpg', 'jpeg'].includes(extn)) {

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Contacts" />
</template>
<template #right-header>
<CustomActions
@ -72,6 +72,7 @@
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import CustomActions from '@/components/CustomActions.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
@ -79,37 +80,15 @@ import ContactModal from '@/components/Modals/ContactModal.vue'
import QuickEntryModal from '@/components/Settings/QuickEntryModal.vue'
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
import ViewControls from '@/components/ViewControls.vue'
import { Breadcrumbs } from 'frappe-ui'
import { organizationsStore } from '@/stores/organizations.js'
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const { getOrganization } = organizationsStore()
const route = useRoute()
const showContactModal = ref(false)
const showQuickEntryModal = ref(false)
const currentContact = computed(() => {
return contacts.value?.data?.data?.find(
(contact) => contact.name === route.params.contactId,
)
})
const breadcrumbs = computed(() => {
let items = [{ label: __('Contacts'), route: { name: 'Contacts' } }]
if (!currentContact.value) return items
items.push({
label: __(currentContact.value.full_name),
route: {
name: 'Contact',
params: { contactId: currentContact.value.name },
},
})
return items
})
const contactsListView = ref(null)
// contacts data is loaded in the ViewControls component

View File

@ -1,7 +1,11 @@
<template>
<LayoutHeader v-if="deal.data">
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
</template>
<template #right-header>
<CustomActions
@ -302,6 +306,7 @@
/>
</template>
<script setup>
import Icon from '@/components/Icon.vue'
import Resizer from '@/components/Resizer.vue'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
@ -337,6 +342,7 @@ import {
errorMessage,
copyToClipboard,
} from '@/utils'
import { getView } from '@/utils/view'
import { globalStore } from '@/stores/global'
import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
@ -353,12 +359,13 @@ import {
usePageMeta,
} from 'frappe-ui'
import { ref, computed, h, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
const { $dialog, makeCall } = globalStore()
const { organizations, getOrganization } = organizationsStore()
const { statusOptions, getDealStatus } = statusesStore()
const { isManager } = usersStore()
const route = useRoute()
const router = useRouter()
const props = defineProps({
@ -452,6 +459,22 @@ function validateRequired(fieldname, value) {
const breadcrumbs = computed(() => {
let items = [{ label: __('Deals'), route: { name: 'Deals' } }]
if (route.query.view || route.query.viewType) {
let view = getView(route.query.view, route.query.viewType, 'CRM Deal')
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Deals',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: organization.value?.name || __('Untitled'),
route: { name: 'Deal', params: { dealId: deal.data.name } },

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Deals" />
</template>
<template #right-header>
<CustomActions
@ -32,7 +32,11 @@
v-if="route.params.viewType == 'kanban'"
v-model="deals"
:options="{
getRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }),
getRoute: (row) => ({
name: 'Deal',
params: { dealId: row.name },
query: { view: route.query.view, viewType: route.params.viewType },
}),
onNewClick: (column) => onNewClick(column),
}"
@update="(data) => viewControls.updateKanbanSettings(data)"
@ -259,6 +263,7 @@
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import CustomActions from '@/components/CustomActions.vue'
import EmailAtIcon from '@/components/Icons/EmailAtIcon.vue'
@ -288,12 +293,10 @@ import {
formatNumberIntoCurrency,
formatTime,
} from '@/utils'
import { Breadcrumbs, Tooltip, Avatar, Dropdown } from 'frappe-ui'
import { Tooltip, Avatar, Dropdown } from 'frappe-ui'
import { useRoute } from 'vue-router'
import { ref, reactive, computed, h } from 'vue'
const breadcrumbs = [{ label: __('Deals'), route: { name: 'Deals' } }]
const { makeCall } = globalStore()
const { getUser } = usersStore()
const { getOrganization } = organizationsStore()

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Email Templates" />
</template>
<template #right-header>
<CustomActions
@ -68,6 +68,7 @@
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import CustomActions from '@/components/CustomActions.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
@ -75,13 +76,8 @@ import ViewControls from '@/components/ViewControls.vue'
import EmailTemplatesListView from '@/components/ListViews/EmailTemplatesListView.vue'
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
import { Breadcrumbs } from 'frappe-ui'
import { computed, ref } from 'vue'
const breadcrumbs = [
{ label: __('Email Templates'), route: { name: 'Email Templates' } },
]
const emailTemplatesListView = ref(null)
// emailTemplates data is loaded in the ViewControls component

View File

@ -1,7 +1,11 @@
<template>
<LayoutHeader v-if="lead.data">
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
</template>
<template #right-header>
<CustomActions
@ -273,6 +277,7 @@
/>
</template>
<script setup>
import Icon from '@/components/Icon.vue'
import Resizer from '@/components/Resizer.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
@ -306,6 +311,7 @@ import {
errorMessage,
copyToClipboard,
} from '@/utils'
import { getView } from '@/utils/view'
import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
@ -421,6 +427,22 @@ function validateRequired(fieldname, value) {
const breadcrumbs = computed(() => {
let items = [{ label: __('Leads'), route: { name: 'Leads' } }]
if (route.query.view || route.query.viewType) {
let view = getView(route.query.view, route.query.viewType, 'CRM Lead')
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Leads',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: lead.data.lead_name || __('Untitled'),
route: { name: 'Lead', params: { leadId: lead.data.name } },

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Leads" />
</template>
<template #right-header>
<CustomActions
@ -33,7 +33,11 @@
v-if="route.params.viewType == 'kanban'"
v-model="leads"
:options="{
getRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }),
getRoute: (row) => ({
name: 'Lead',
params: { leadId: row.name },
query: { view: route.query.view, viewType: route.params.viewType },
}),
onNewClick: (column) => onNewClick(column),
}"
@update="(data) => viewControls.updateKanbanSettings(data)"
@ -281,6 +285,7 @@
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import CustomActions from '@/components/CustomActions.vue'
import EmailAtIcon from '@/components/Icons/EmailAtIcon.vue'
@ -304,12 +309,10 @@ import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
import { callEnabled } from '@/composables/settings'
import { dateFormat, dateTooltipFormat, timeAgo, formatTime } from '@/utils'
import { Breadcrumbs, Avatar, Tooltip, Dropdown } from 'frappe-ui'
import { Avatar, Tooltip, Dropdown } from 'frappe-ui'
import { useRoute } from 'vue-router'
import { ref, computed, reactive, h } from 'vue'
const breadcrumbs = [{ label: __('Leads'), route: { name: 'Leads' } }]
const { makeCall } = globalStore()
const { getUser } = usersStore()
const { getOrganization } = organizationsStore()

View File

@ -3,7 +3,11 @@
<header
class="relative flex h-12 items-center justify-between gap-2 py-2.5 pl-5"
>
<Breadcrumbs :items="breadcrumbs" />
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
<div class="absolute right-0">
<Dropdown :options="statusOptions('deal', updateField)">
<template #default="{ open }">
@ -245,6 +249,7 @@
/>
</template>
<script setup>
import Icon from '@/components/Icon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
@ -269,16 +274,16 @@ import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue'
import {
createToast,
setupAssignees,
setupCustomActions,
errorMessage,
} from '@/utils'
import { createToast, setupAssignees, setupCustomActions } from '@/utils'
import { getView } from '@/utils/view'
import { globalStore } from '@/stores/global'
import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
import { whatsappEnabled, callEnabled, isMobileView } from '@/composables/settings'
import {
whatsappEnabled,
callEnabled,
isMobileView,
} from '@/composables/settings'
import {
createResource,
Dropdown,
@ -288,11 +293,12 @@ import {
call,
} from 'frappe-ui'
import { ref, computed, h, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
const { $dialog, makeCall } = globalStore()
const { $dialog } = globalStore()
const { organizations, getOrganization } = organizationsStore()
const { statusOptions, getDealStatus } = statusesStore()
const route = useRoute()
const router = useRouter()
const props = defineProps({
@ -385,6 +391,22 @@ function validateRequired(fieldname, value) {
const breadcrumbs = computed(() => {
let items = [{ label: __('Deals'), route: { name: 'Deals' } }]
if (route.query.view || route.query.viewType) {
let view = getView(route.query.view, route.query.viewType, 'CRM Deal')
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Deals',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: organization.value?.name || __('Untitled'),
route: { name: 'Deal', params: { dealId: deal.data.name } },

View File

@ -3,7 +3,11 @@
<header
class="relative flex h-12 items-center justify-between gap-2 py-2.5 pl-5"
>
<Breadcrumbs :items="breadcrumbs" />
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
<div class="absolute right-0">
<Dropdown :options="statusOptions('lead', updateField)">
<template #default="{ open }">
@ -138,7 +142,7 @@
<div v-else class="mt-2.5 text-base">
{{
__(
'New organization will be created based on the data in details section'
'New organization will be created based on the data in details section',
)
}}
</div>
@ -170,6 +174,7 @@
</Dialog>
</template>
<script setup>
import Icon from '@/components/Icon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
@ -191,11 +196,16 @@ import SectionFields from '@/components/SectionFields.vue'
import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue'
import { createToast, setupAssignees, setupCustomActions } from '@/utils'
import { getView } from '@/utils/view'
import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
import { whatsappEnabled, callEnabled, isMobileView } from '@/composables/settings'
import {
whatsappEnabled,
callEnabled,
isMobileView,
} from '@/composables/settings'
import {
createResource,
Dropdown,
@ -298,6 +308,22 @@ function validateRequired(fieldname, value) {
const breadcrumbs = computed(() => {
let items = [{ label: __('Leads'), route: { name: 'Leads' } }]
if (route.query.view || route.query.viewType) {
let view = getView(route.query.view, route.query.viewType, 'CRM Lead')
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Leads',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: lead.data.lead_name || __('Untitled'),
route: { name: 'Lead', params: { leadId: lead.data.name } },
@ -359,7 +385,7 @@ const tabs = computed(() => {
watch(tabs, (value) => {
if (value && route.params.tabName) {
let index = value.findIndex(
(tab) => tab.name.toLowerCase() === route.params.tabName.toLowerCase()
(tab) => tab.name.toLowerCase() === route.params.tabName.toLowerCase(),
)
if (index !== -1) {
tabIndex.value = index
@ -447,7 +473,7 @@ async function convertToDeal(updated) {
organization: lead.data.organization,
},
'',
() => convertToDeal(true)
() => convertToDeal(true),
)
showConvertToDealModal.value = false
} else {
@ -455,7 +481,7 @@ async function convertToDeal(updated) {
'crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal',
{
lead: lead.data.name,
}
},
)
if (deal) {
if (updated) {

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Notes" />
</template>
<template #right-header>
<Button variant="solid" :label="__('Create')" @click="createNote">
@ -10,6 +10,7 @@
</template>
</LayoutHeader>
<ViewControls
ref="viewControls"
v-model="notes"
v-model:loadMore="loadMore"
v-model:updatedPageCount="updatedPageCount"
@ -102,6 +103,7 @@
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
@ -109,33 +111,25 @@ import NoteModal from '@/components/Modals/NoteModal.vue'
import ViewControls from '@/components/ViewControls.vue'
import { usersStore } from '@/stores/users'
import { timeAgo, dateFormat, dateTooltipFormat } from '@/utils'
import {
TextEditor,
call,
Dropdown,
Tooltip,
Breadcrumbs,
ListFooter,
} from 'frappe-ui'
import { TextEditor, call, Dropdown, Tooltip, ListFooter } from 'frappe-ui'
import { ref, watch } from 'vue'
const { getUser } = usersStore()
const breadcrumbs = [{ label: __('Notes'), route: { name: 'Notes' } }]
const showNoteModal = ref(false)
const currentNote = ref(null)
const notes = ref({})
const loadMore = ref(1)
const updatedPageCount = ref(20)
const viewControls = ref(null)
watch(
() => notes.value?.data?.page_length_count,
(val, old_value) => {
if (!val || val === old_value) return
updatedPageCount.value = val
}
},
)
function createNote() {

View File

@ -1,7 +1,11 @@
<template>
<LayoutHeader v-if="organization.doc">
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
</template>
</LayoutHeader>
<div v-if="organization.doc" class="flex flex-1 flex-col overflow-hidden">
@ -226,17 +230,7 @@
</template>
<script setup>
import {
Breadcrumbs,
Avatar,
FileUploader,
Dropdown,
Tabs,
call,
createListResource,
createDocumentResource,
usePageMeta,
} from 'frappe-ui'
import Icon from '@/components/Icon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import QuickEntryModal from '@/components/Settings/QuickEntryModal.vue'
@ -252,14 +246,26 @@ import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { getView } from '@/utils/view'
import {
dateFormat,
dateTooltipFormat,
timeAgo,
formatNumberIntoCurrency,
} from '@/utils'
import {
Breadcrumbs,
Avatar,
FileUploader,
Dropdown,
Tabs,
call,
createListResource,
createDocumentResource,
usePageMeta,
} from 'frappe-ui'
import { h, computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps({
organizationId: {
@ -274,6 +280,7 @@ const showOrganizationModal = ref(false)
const showQuickEntryModal = ref(false)
const detailMode = ref(false)
const route = useRoute()
const router = useRouter()
const organization = createDocumentResource({
@ -286,6 +293,26 @@ const organization = createDocumentResource({
const breadcrumbs = computed(() => {
let items = [{ label: __('Organizations'), route: { name: 'Organizations' } }]
if (route.query.view || route.query.viewType) {
let view = getView(
route.query.view,
route.query.viewType,
'CRM Organization',
)
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Organizations',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: props.organizationId,
route: {

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Organizations" />
</template>
<template #right-header>
<CustomActions
@ -70,6 +70,7 @@
/>
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import CustomActions from '@/components/CustomActions.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
@ -77,7 +78,6 @@ import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import QuickEntryModal from '@/components/Settings/QuickEntryModal.vue'
import OrganizationsListView from '@/components/ListViews/OrganizationsListView.vue'
import ViewControls from '@/components/ViewControls.vue'
import { Breadcrumbs } from 'frappe-ui'
import {
dateFormat,
dateTooltipFormat,
@ -85,33 +85,11 @@ import {
formatNumberIntoCurrency,
} from '@/utils'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const organizationsListView = ref(null)
const showOrganizationModal = ref(false)
const showQuickEntryModal = ref(false)
const currentOrganization = computed(() => {
return organizations.value?.data?.data?.find(
(organization) => organization.name === route.params.organizationId,
)
})
const breadcrumbs = computed(() => {
let items = [{ label: __('Organizations'), route: { name: 'Organizations' } }]
if (!currentOrganization.value) return items
items.push({
label: __(currentOrganization.value.name),
route: {
name: 'Organization',
params: { organizationId: currentOrganization.value.name },
},
})
return items
})
// organizations data is loaded in the ViewControls component
const organizations = ref({})
const loadMore = ref(1)

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
<ViewBreadcrumbs v-model="viewControls" routeName="Tasks" />
</template>
<template #right-header>
<CustomActions
@ -193,6 +193,7 @@
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import CustomActions from '@/components/CustomActions.vue'
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
@ -205,19 +206,10 @@ import KanbanView from '@/components/Kanban/KanbanView.vue'
import TaskModal from '@/components/Modals/TaskModal.vue'
import { usersStore } from '@/stores/users'
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
import {
Breadcrumbs,
Tooltip,
Avatar,
TextEditor,
Dropdown,
call,
} from 'frappe-ui'
import { Tooltip, Avatar, TextEditor, Dropdown, call } from 'frappe-ui'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const breadcrumbs = [{ label: __('Tasks'), route: { name: 'Tasks' } }]
const { getUser } = usersStore()
const router = useRouter()

View File

@ -40,7 +40,8 @@ const routes = [
props: true,
},
{
path: '/notes',
alias: '/notes',
path: '/notes/view/:viewType?',
name: 'Notes',
component: () => import('@/pages/Notes.vue'),
},
@ -51,7 +52,8 @@ const routes = [
component: () => import('@/pages/Tasks.vue'),
},
{
path: '/contacts',
alias: '/contacts',
path: '/contacts/view/:viewType?',
name: 'Contacts',
component: () => import('@/pages/Contacts.vue'),
meta: { scrollPos: { top: 0, left: 0 } },
@ -63,7 +65,8 @@ const routes = [
props: true,
},
{
path: '/organizations',
alias: '/organizations',
path: '/organizations/view/:viewType?',
name: 'Organizations',
component: () => import('@/pages/Organizations.vue'),
meta: { scrollPos: { top: 0, left: 0 } },
@ -75,13 +78,15 @@ const routes = [
props: true,
},
{
path: '/call-logs',
alias: '/call-logs',
path: '/call-logs/view/:viewType?',
name: 'Call Logs',
component: () => import('@/pages/CallLogs.vue'),
meta: { scrollPos: { top: 0, left: 0 } },
},
{
path: '/email-templates',
alias: '/email-templates',
path: '/email-templates/view/:viewType?',
name: 'Email Templates',
component: () => import('@/pages/EmailTemplates.vue'),
meta: { scrollPos: { top: 0, left: 0 } },
@ -92,11 +97,6 @@ const routes = [
component: () => import('@/pages/EmailTemplate.vue'),
props: true,
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/pages/Dashboard.vue'),
},
{
path: '/:invalidpath',
name: 'Invalid Page',

View File

@ -1,6 +1,5 @@
import '../../../frappe/frappe/public/js/lib/posthog.js'
import { createResource } from 'frappe-ui'
import { computed } from 'vue'
declare global {
interface Window {

View File

@ -216,3 +216,7 @@ export function isEmoji(str) {
export function isTouchScreenDevice() {
return "ontouchstart" in document.documentElement;
}
export function convertArrayToString(array) {
return array.map((item) => item).join(',')
}

View File

@ -0,0 +1,35 @@
import ListIcon from '@/components/Icons/ListIcon.vue'
import GroupByIcon from '@/components/Icons/GroupByIcon.vue'
import KanbanIcon from '@/components/Icons/KanbanIcon.vue'
import { viewsStore } from '@/stores/views'
import { markRaw } from 'vue'
const { getView: getViewDetails } = viewsStore()
function defaultView(type) {
let types = {
list: {
label: __('List'),
icon: markRaw(ListIcon),
},
group_by: {
label: __('Group By'),
icon: markRaw(GroupByIcon),
},
kanban: {
label: __('Kanban'),
icon: markRaw(KanbanIcon),
},
}
return types[type]
}
export function getView(view, type, doctype) {
let viewType = type || 'list'
let viewDetails = getViewDetails(view, viewType, doctype)
if (viewDetails && !viewDetails.icon) {
viewDetails.icon = defaultView(viewType).icon
}
return viewDetails || defaultView(viewType)
}