Merge pull request #26 from shariquerik/contact-linking

feat: Contact linking
This commit is contained in:
Shariq Ansari 2023-11-16 18:26:42 +05:30 committed by GitHub
commit 6180899a3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1460 additions and 572 deletions

107
crm/api/contact.py Normal file
View File

@ -0,0 +1,107 @@
import frappe
def validate(doc, method):
set_primary_email(doc)
set_primary_mobile_no(doc)
doc.set_primary_email()
doc.set_primary("mobile_no")
def set_primary_email(doc):
if not doc.email_ids:
return
if len(doc.email_ids) == 1:
doc.email_ids[0].is_primary = 1
def set_primary_mobile_no(doc):
if not doc.phone_nos:
return
if len(doc.phone_nos) == 1:
doc.phone_nos[0].is_primary_mobile_no = 1
@frappe.whitelist()
def get_linked_deals(contact):
"""Get linked deals for a contact"""
if not frappe.has_permission("Contact", "read", contact):
frappe.throw("Not permitted", frappe.PermissionError)
deal_names = frappe.get_all(
"CRM Contacts",
filters={"contact": contact, "parenttype": "CRM Deal"},
fields=["parent"],
distinct=True,
)
# get deals data
deals = []
for d in deal_names:
deal = frappe.get_cached_doc(
"CRM Deal",
d.parent,
fields=[
"name",
"organization",
"annual_revenue",
"status",
"email",
"mobile_no",
"deal_owner",
"modified",
],
)
deals.append(deal.as_dict())
return deals
@frappe.whitelist()
def create_new(contact, field, value):
"""Create new email or phone for a contact"""
if not frappe.has_permission("Contact", "write", contact):
frappe.throw("Not permitted", frappe.PermissionError)
contact = frappe.get_doc("Contact", contact)
if field == "email":
contact.append("email_ids", {"email_id": value})
elif field in ("mobile_no", "phone"):
contact.append("phone_nos", {"phone": value})
else:
frappe.throw("Invalid field")
contact.save()
return True
@frappe.whitelist()
def set_as_primary(contact, field, value):
"""Set email or phone as primary for a contact"""
if not frappe.has_permission("Contact", "write", contact):
frappe.throw("Not permitted", frappe.PermissionError)
contact = frappe.get_doc("Contact", contact)
if field == "email":
for email in contact.email_ids:
if email.email_id == value:
email.is_primary = 1
else:
email.is_primary = 0
elif field in ("mobile_no", "phone"):
name = "is_primary_mobile_no" if field == "mobile_no" else "is_primary_phone"
for phone in contact.phone_nos:
if phone.phone == value:
phone.set(name, 1)
else:
phone.set(name, 0)
else:
frappe.throw("Invalid field")
contact.save()
return True

View File

@ -23,12 +23,37 @@ def get_contacts():
if frappe.session.user == "Guest":
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
contacts = frappe.qb.get_query(
contacts = frappe.get_all(
"Contact",
fields=['name', 'first_name', 'last_name', 'full_name', 'image', 'email_id', 'mobile_no', 'phone', 'salutation', 'company_name', 'modified'],
fields=[
"name",
"salutation",
"first_name",
"last_name",
"full_name",
"image",
"email_id",
"mobile_no",
"phone",
"company_name",
"modified"
],
order_by="first_name asc",
distinct=True,
).run(as_dict=1)
)
for contact in contacts:
contact["email_ids"] = frappe.get_all(
"Contact Email",
filters={"parenttype": "Contact", "parent": contact.name},
fields=["email_id", "is_primary"],
)
contact["phone_nos"] = frappe.get_all(
"Contact Phone",
filters={"parenttype": "Contact", "parent": contact.name},
fields=["phone", "is_primary_phone", "is_primary_mobile_no"],
)
return contacts
@ -39,7 +64,7 @@ def get_organizations():
organizations = frappe.qb.get_query(
"CRM Organization",
fields=['name', 'organization_name', 'organization_logo', 'website'],
fields=['*'],
order_by="name asc",
distinct=True,
).run(as_dict=1)

View File

@ -12,7 +12,8 @@
"column_break_uvny",
"gender",
"mobile_no",
"phone"
"phone",
"is_primary"
],
"fields": [
{
@ -55,7 +56,6 @@
"fetch_from": "contact.phone",
"fieldname": "phone",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Phone",
"options": "Phone",
"read_only": 1
@ -64,16 +64,22 @@
"fetch_from": "contact.gender",
"fieldname": "gender",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Gender",
"options": "Gender",
"read_only": 1
},
{
"default": "0",
"fieldname": "is_primary",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Is Primary"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-08-25 19:19:27.813526",
"modified": "2023-11-12 14:58:18.846919",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Contacts",

View File

@ -1,9 +1,5 @@
import json
import frappe
from frappe import _
from frappe.desk.form.load import get_docinfo
from crm.fcrm.doctype.crm_lead.api import get_activities as get_lead_activities
@frappe.whitelist()
@ -22,4 +18,11 @@ def get_deal(name):
frappe.throw(_("Deal not found"), frappe.DoesNotExistError)
deal = deal.pop()
deal["contacts"] = frappe.get_all(
"CRM Contacts",
filters={"parenttype": "CRM Deal", "parent": deal.name},
fields=["contact", "is_primary"],
)
return deal

View File

@ -21,7 +21,8 @@
"column_break_bqvs",
"contacts_tab",
"email",
"mobile_no"
"mobile_no",
"contacts"
],
"fields": [
{
@ -114,11 +115,17 @@
"options": "Qualification\nDemo/Making\nProposal/Quotation\nNegotiation\nReady to Close\nWon\nLost",
"reqd": 1,
"search_index": 1
},
{
"fieldname": "contacts",
"fieldtype": "Table",
"label": "Contacts",
"options": "CRM Contacts"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-06 21:53:50.442404",
"modified": "2023-11-09 19:58:15.620483",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

@ -1,11 +1,50 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe import _
from frappe.model.document import Document
class CRMDeal(Document):
def validate(self):
self.set_primary_contact()
self.set_primary_email_mobile_no()
def set_primary_contact(self, contact=None):
if not self.contacts:
return
if not contact and len(self.contacts) == 1:
self.contacts[0].is_primary = 1
elif contact:
for d in self.contacts:
if d.contact == contact:
d.is_primary = 1
else:
d.is_primary = 0
def set_primary_email_mobile_no(self):
if not self.contacts:
self.email = ""
self.mobile_no = ""
return
if len([contact for contact in self.contacts if contact.is_primary]) > 1:
frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold("Contact")))
primary_contact_exists = False
for d in self.contacts:
if d.is_primary == 1:
primary_contact_exists = True
self.email = d.email.strip()
self.mobile_no = d.mobile_no.strip()
break
if not primary_contact_exists:
self.email = ""
self.mobile_no = ""
@staticmethod
def sort_options():
return [
@ -17,3 +56,33 @@ class CRMDeal(Document):
{ "label": 'Email', "value": 'email' },
{ "label": 'Mobile no', "value": 'mobile_no' },
]
@frappe.whitelist()
def add_contact(deal, contact):
if not frappe.has_permission("CRM Deal", "write", deal):
frappe.throw(_("Not allowed to add contact to Deal"), frappe.PermissionError)
deal = frappe.get_cached_doc("CRM Deal", deal)
deal.append("contacts", {"contact": contact})
deal.save()
return True
@frappe.whitelist()
def remove_contact(deal, contact):
if not frappe.has_permission("CRM Deal", "write", deal):
frappe.throw(_("Not allowed to remove contact from Deal"), frappe.PermissionError)
deal = frappe.get_cached_doc("CRM Deal", deal)
deal.contacts = [d for d in deal.contacts if d.contact != contact]
deal.save()
return True
@frappe.whitelist()
def set_primary_contact(deal, contact):
if not frappe.has_permission("CRM Deal", "write", deal):
frappe.throw(_("Not allowed to set primary contact for Deal"), frappe.PermissionError)
deal = frappe.get_cached_doc("CRM Deal", deal)
deal.set_primary_contact(contact)
deal.save()
return True

View File

@ -20,6 +20,7 @@
"column_break_lcuv",
"lead_owner",
"status",
"job_title",
"source",
"converted",
"organization_tab",
@ -28,7 +29,6 @@
"no_of_employees",
"column_break_dbsv",
"website",
"job_title",
"annual_revenue",
"industry",
"contact_tab",
@ -37,9 +37,7 @@
"column_break_sijm",
"mobile_no",
"column_break_sjtw",
"phone",
"section_break_jyxr",
"contacts"
"phone"
],
"fields": [
{
@ -187,11 +185,9 @@
"search_index": 1
},
{
"fetch_from": "organization.job_title",
"fieldname": "job_title",
"fieldtype": "Data",
"label": "Job Title",
"read_only": 1
"label": "Job Title"
},
{
"fieldname": "organization_tab",
@ -203,16 +199,6 @@
"fieldtype": "Tab Break",
"label": "Contact"
},
{
"fieldname": "contacts",
"fieldtype": "Table",
"label": "Contacts",
"options": "CRM Contacts"
},
{
"fieldname": "section_break_jyxr",
"fieldtype": "Section Break"
},
{
"fieldname": "organization",
"fieldtype": "Link",
@ -231,7 +217,7 @@
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-06 21:53:32.542503",
"modified": "2023-11-13 13:35:35.783003",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead",

View File

@ -14,9 +14,7 @@ class CRMLead(Document):
self.set_lead_name()
self.set_title()
self.validate_email()
if not self.is_new():
self.validate_contact()
def set_full_name(self):
if self.first_name:
self.lead_name = " ".join(
@ -37,7 +35,7 @@ class CRMLead(Document):
def set_title(self):
self.title = self.organization or self.lead_name
def validate_email(self):
if self.email:
if not self.flags.ignore_email_validation:
@ -48,58 +46,15 @@ class CRMLead(Document):
if self.is_new() or not self.image:
self.image = has_gravatar(self.email)
def validate_contact(self):
link = frappe.db.exists("Dynamic Link", {"link_doctype": "CRM Lead", "link_name": self.name})
if link:
for field in ["first_name", "last_name", "email", "mobile_no", "phone", "salutation", "image"]:
if self.has_value_changed(field):
contact = frappe.db.get_value("Dynamic Link", link, "parent")
contact_doc = frappe.get_doc("Contact", contact)
contact_doc.update({
"first_name": self.first_name or self.lead_name,
"last_name": self.last_name,
"salutation": self.salutation,
"image": self.image or "",
})
if self.has_value_changed("email"):
contact_doc.email_ids = []
contact_doc.append("email_ids", {"email_id": self.email, "is_primary": 1})
if self.has_value_changed("phone"):
contact_doc.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1})
if self.has_value_changed("mobile_no"):
contact_doc.phone_nos = []
contact_doc.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1})
contact_doc.save()
break
else:
self.contact_doc = self.create_contact()
self.link_to_contact()
def before_insert(self):
self.contact_doc = None
self.contact_doc = self.create_contact()
def after_insert(self):
self.link_to_contact()
def link_to_contact(self):
# update contact links
if self.contact_doc:
self.contact_doc.append(
"links", {"link_doctype": "CRM Lead", "link_name": self.name, "link_title": self.lead_name}
)
self.contact_doc.save()
def create_contact(self):
if not self.lead_name:
self.set_full_name()
self.set_lead_name()
if self.contact_exists():
return
contact = frappe.new_doc("Contact")
contact.update(
{
@ -125,7 +80,40 @@ class CRMLead(Document):
contact.insert(ignore_permissions=True)
contact.reload() # load changes by hooks on contact
return contact
return contact.name
def contact_exists(self):
email_exist = frappe.db.exists("Contact Email", {"email_id": self.email})
phone_exist = frappe.db.exists("Contact Phone", {"phone": self.phone})
mobile_exist = frappe.db.exists("Contact Phone", {"phone": self.mobile_no})
if email_exist or phone_exist or mobile_exist:
text = "Email" if email_exist else "Phone" if phone_exist else "Mobile No"
data = self.email if email_exist else self.phone if phone_exist else self.mobile_no
value = "{0}: {1}".format(text, data)
frappe.throw(
_("Contact already exists with {0}").format(value),
title=_("Contact Already Exists"),
)
return True
return False
def create_deal(self, contact):
deal = frappe.new_doc("CRM Deal")
deal.update(
{
"lead": self.name,
"organization": self.organization,
"deal_owner": self.lead_owner,
"contacts": [{"contact": contact}],
}
)
deal.insert(ignore_permissions=True)
return deal.name
@staticmethod
def sort_options():
@ -140,4 +128,17 @@ class CRMLead(Document):
{ "label": 'Last Name', "value": 'last_name' },
{ "label": 'Email', "value": 'email' },
{ "label": 'Mobile no', "value": 'mobile_no' },
]
]
@frappe.whitelist()
def convert_to_deal(lead):
if not frappe.has_permission("CRM Lead", "write", lead):
frappe.throw(_("Not allowed to convert Lead to Deal"), frappe.PermissionError)
lead = frappe.get_cached_doc("CRM Lead", lead)
lead.status = "Qualified"
lead.converted = 1
contact = lead.create_contact()
deal = lead.create_deal(contact)
lead.save()
return deal

View File

@ -11,7 +11,6 @@
"organization_logo",
"column_break_pnpp",
"website",
"job_title",
"annual_revenue",
"industry"
],
@ -43,11 +42,6 @@
"fieldname": "column_break_pnpp",
"fieldtype": "Column Break"
},
{
"fieldname": "job_title",
"fieldtype": "Data",
"label": "Job Title"
},
{
"fieldname": "annual_revenue",
"fieldtype": "Currency",
@ -63,7 +57,7 @@
"image_field": "organization_logo",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-06 15:28:26.610882",
"modified": "2023-11-13 13:32:39.029742",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Organization",

View File

@ -125,13 +125,11 @@ website_route_rules = [
# ---------------
# Hook on document methods and events
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
doc_events = {
"Contact": {
"validate": ["crm.api.contact.validate"],
},
}
# Scheduled Tasks
# ---------------

View File

@ -0,0 +1,124 @@
<template>
<div class="space-y-1.5">
<label class="block" :class="labelClasses" v-if="attrs.label">
{{ attrs.label }}
</label>
<Autocomplete
ref="autocomplete"
:options="options.data"
v-model="value"
:size="attrs.size || 'sm'"
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />
</template>
<template #prefix>
<slot name="prefix" />
</template>
<template #item-prefix="{ active, selected, option }">
<slot name="item-prefix" v-bind="{ active, selected, option }" />
</template>
<template v-if="attrs.onCreate" #footer="{ value, close }">
<div>
<Button
variant="ghost"
class="w-full !justify-start"
label="Create one"
@click="attrs.onCreate(value, close)"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</template>
</Autocomplete>
</div>
</template>
<script setup>
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { watchDebounced } from '@vueuse/core'
import { createResource, FeatherIcon } from 'frappe-ui'
import { useAttrs, computed, ref } from 'vue'
const props = defineProps({
doctype: {
type: String,
required: true,
},
modelValue: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs)
const value = computed({
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
set: (val) => {
return (
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
)
},
})
const autocomplete = ref(null)
const text = ref('')
watchDebounced(
() => autocomplete.value?.query,
(val) => {
if (text.value === val) return
text.value = val
options.update({
params: {
txt: val,
doctype: props.doctype,
},
})
options.reload()
},
{ debounce: 300, immediate: true }
)
const options = createResource({
url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value],
method: 'POST',
params: {
txt: text.value,
doctype: props.doctype,
},
transform: (data) => {
return data.map((option) => {
return {
label: option.value,
value: option.value,
}
})
},
})
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[attrs.size || 'sm'],
'text-gray-600',
]
})
</script>

View File

@ -0,0 +1,41 @@
<template>
<button
:class="[
active ? 'bg-gray-100' : 'text-gray-800',
'group flex h-7 w-full items-center justify-between gap-3 rounded px-2 text-base',
]"
@click="onClick"
>
<span class="whitespace-nowrap">
{{ value }}
</span>
<FeatherIcon
v-if="selected"
name="check"
class="text-primary-500 h-4 w-4"
size="sm"
/>
</button>
</template>
<script setup>
import { FeatherIcon } from 'frappe-ui'
const props = defineProps({
value: {
type: String,
default: '',
},
onClick: {
type: Function,
default: () => {},
},
active: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
})
</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="M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 14 4.68629 14 8ZM15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8ZM11.2909 5.98482C11.4666 5.77175 11.4363 5.45663 11.2232 5.28096C11.0101 5.1053 10.695 5.13561 10.5193 5.34868L7.07001 9.53239L5.72845 7.79857C5.55946 7.58018 5.24543 7.54012 5.02703 7.70911C4.80863 7.8781 4.76858 8.19214 4.93756 8.41053L6.66217 10.6394C6.7552 10.7596 6.89788 10.831 7.04988 10.8334C7.20188 10.8357 7.3467 10.7688 7.4434 10.6515L11.2909 5.98482Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -4,6 +4,7 @@
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Contact', params: { contactId: row.name } }),
selectable: options.selectable,
}"
row-key="name"
>
@ -70,5 +71,11 @@ const props = defineProps({
type: Array,
required: true,
},
options: {
type: Object,
default: () => ({
selectable: true,
}),
},
})
</script>

View File

@ -4,6 +4,7 @@
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }),
selectable: options.selectable,
}"
row-key="name"
>
@ -75,5 +76,11 @@ const props = defineProps({
type: Array,
required: true,
},
options: {
type: Object,
default: () => ({
selectable: true,
}),
},
})
</script>

View File

@ -4,6 +4,7 @@
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }),
selectable: options.selectable,
}"
row-key="name"
>
@ -84,5 +85,11 @@ const props = defineProps({
type: Array,
required: true,
},
options: {
type: Object,
default: () => ({
selectable: true,
}),
},
})
</script>

View File

@ -9,19 +9,21 @@
label: editMode ? 'Update' : 'Create',
variant: 'solid',
disabled: !dirty,
onClick: ({ close }) => updateContact(close),
onClick: ({ close }) =>
editMode ? updateContact(close) : callInsertDoc(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
type="text"
size="md"
<Link
variant="outline"
size="md"
label="Salutation"
v-model="_contact.salutation"
doctype="Salutation"
placeholder="Mr./Mrs./Ms..."
/>
<div class="flex gap-4">
<FormControl
@ -41,12 +43,13 @@
v-model="_contact.last_name"
/>
</div>
<FormControl
type="text"
<Link
variant="outline"
size="md"
label="Organisation"
label="Organization"
v-model="_contact.company_name"
doctype="CRM Organization"
placeholder="Select organization"
/>
<div class="flex gap-4">
<FormControl
@ -72,16 +75,26 @@
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { FormControl, Dialog, call } from 'frappe-ui'
import { ref, defineModel, nextTick, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
contact: {
type: Object,
default: {},
},
options: {
type: Object,
default: {
redirect: true,
afterInsert: () => {},
},
},
})
const router = useRouter()
const show = defineModel()
const contacts = defineModel('reloadContacts')
@ -89,31 +102,55 @@ const editMode = ref(false)
let _contact = ref({})
async function updateContact(close) {
if (JSON.stringify(props.contact) === JSON.stringify(_contact.value)) {
if (!dirty.value) {
close()
return
}
if (_contact.value.name) {
let d = await call('frappe.client.set_value', {
doctype: 'Contact',
name: _contact.value.name,
fieldname: _contact.value,
})
if (d.name) {
contacts.value.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'Contact',
..._contact.value,
},
})
if (d.name) {
contacts.value.reload()
}
let name = await callSetValue(values)
handleContactUpdate({ name }, close)
}
async function callSetValue(values) {
const d = await call('frappe.client.set_value', {
doctype: 'Contact',
name: _contact.value.name,
fieldname: values,
})
return d.name
}
async function callInsertDoc(close) {
if (_contact.value.email_id) {
_contact.value.email_ids = [{ email_id: _contact.value.email_id }]
delete _contact.value.email_id
}
close()
if (_contact.value.mobile_no) {
_contact.value.phone_nos = [{ phone: _contact.value.mobile_no }]
delete _contact.value.mobile_no
}
const doc = await call('frappe.client.insert', {
doc: {
doctype: 'Contact',
..._contact.value,
},
})
doc.name && handleContactUpdate(doc, close)
}
function handleContactUpdate(doc, close) {
contacts.value?.reload()
if (doc.name && props.options.redirect) {
router.push({
name: 'Contact',
params: { contactId: doc.name },
})
}
close && close()
props.options.afterInsert && props.options.afterInsert(doc)
}
const dirty = computed(() => {
@ -127,7 +164,7 @@ watch(
editMode.value = false
nextTick(() => {
_contact.value = { ...props.contact }
if (_contact.value.first_name) {
if (_contact.value.name) {
editMode.value = true
}
})

View File

@ -16,22 +16,63 @@
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">Organization name</div>
<TextInput
ref="title"
variant="outline"
v-model="_organization.organization_name"
placeholder="Add organization name"
/>
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">Website</div>
<TextInput
<FormControl
type="text"
ref="title"
size="md"
label="Organization name"
variant="outline"
v-model="_organization.organization_name"
placeholder="Add organization name"
/>
<div class="flex gap-4">
<FormControl
class="flex-1"
type="text"
size="md"
label="Website"
variant="outline"
v-model="_organization.website"
placeholder="Add website"
/>
<FormControl
class="flex-1"
type="text"
size="md"
label="Annual revenue"
variant="outline"
v-model="_organization.annual_revenue"
placeholder="Add annual revenue"
/>
</div>
<div class="flex gap-4">
<FormControl
class="flex-1"
type="select"
:options="[
'1-10',
'11-50',
'51-200',
'201-500',
'501-1000',
'1001-5000',
'5001-10000',
'10001+',
]"
size="md"
label="No. of employees"
variant="outline"
v-model="_organization.no_of_employees"
/>
<Link
class="flex-1"
size="md"
label="Industry"
variant="outline"
v-model="_organization.industry"
doctype="CRM Industry"
placeholder="Add industry"
/>
</div>
</div>
</template>
@ -39,7 +80,8 @@
</template>
<script setup>
import { TextInput, Dialog, call } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { FormControl, Dialog, call } from 'frappe-ui'
import { ref, defineModel, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
@ -57,13 +99,19 @@ const props = defineProps({
},
})
const router = useRouter()
const show = defineModel()
const organizations = defineModel('reloadOrganizations')
const title = ref(null)
const editMode = ref(false)
let _organization = ref({})
const router = useRouter()
let _organization = ref({
organization_name: '',
website: '',
annual_revenue: '',
no_of_employees: '1-10',
industry: '',
})
async function updateOrganization(close) {
const old = { ...props.organization }
@ -138,7 +186,8 @@ watch(
if (!value) return
editMode.value = false
nextTick(() => {
title.value.el.focus()
// TODO: Issue with FormControl
// title.value.el.focus()
_organization.value = { ...props.organization }
if (_organization.value.name) {
editMode.value = true

View File

@ -19,14 +19,14 @@
type="email"
v-model="newDeal[field.name]"
/>
<FormControl
<Link
v-else-if="field.type === 'link'"
type="autocomplete"
class="form-control"
:value="newDeal[field.name]"
:options="field.options"
:doctype="field.doctype"
@change="(e) => field.change(e)"
:placeholder="field.placeholder"
class="form-control"
:onCreate="field.create"
/>
<FormControl
v-else-if="field.type === 'user'"
@ -73,18 +73,27 @@
</div>
</div>
</div>
<OrganizationModal
v-model="showOrganizationModal"
:organization="_organization"
:options="{
redirect: false,
afterInsert: (doc) => (newLead.organization = doc.name),
}"
/>
</template>
<script setup>
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import { usersStore } from '@/stores/users'
import { organizationsStore } from '@/stores/organizations'
import { dealStatuses, statusDropdownOptions, activeAgents } from '@/utils'
import { FormControl, Button, Dropdown, FeatherIcon } from 'frappe-ui'
import { ref } from 'vue'
const { getUser } = usersStore()
const { getOrganizationOptions } = organizationsStore()
const props = defineProps({
newDeal: {
@ -93,6 +102,9 @@ const props = defineProps({
},
})
const showOrganizationModal = ref(false)
const _organization = ref({})
const allFields = [
{
section: 'Deal Details',
@ -146,9 +158,12 @@ const allFields = [
name: 'organization',
type: 'link',
placeholder: 'Organization',
options: getOrganizationOptions(),
change: (option) => {
newDeal.organization = option.name
doctype: 'CRM Organization',
change: (data) => (newDeal.organization = data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
},
{

View File

@ -19,14 +19,14 @@
type="email"
v-model="newLead[field.name]"
/>
<FormControl
<Link
v-else-if="field.type === 'link'"
type="autocomplete"
class="form-control"
:value="newLead[field.name]"
:options="field.options"
:doctype="field.doctype"
@change="(e) => field.change(e)"
:placeholder="field.placeholder"
class="form-control"
:onCreate="field.create"
/>
<FormControl
v-else-if="field.type === 'user'"
@ -73,18 +73,27 @@
</div>
</div>
</div>
<OrganizationModal
v-model="showOrganizationModal"
:organization="_organization"
:options="{
redirect: false,
afterInsert: (doc) => (newLead.organization = doc.name),
}"
/>
</template>
<script setup>
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { organizationsStore } from '@/stores/organizations'
import { leadStatuses, statusDropdownOptions, activeAgents } from '@/utils'
import { FormControl, Button, Dropdown, FeatherIcon } from 'frappe-ui'
import { ref } from 'vue'
const { getUser } = usersStore()
const { getOrganizationOptions } = organizationsStore()
const props = defineProps({
newLead: {
@ -93,6 +102,9 @@ const props = defineProps({
},
})
const showOrganizationModal = ref(false)
const _organization = ref({})
const allFields = [
{
section: 'Lead Details',
@ -146,9 +158,12 @@ const allFields = [
name: 'organization',
type: 'link',
placeholder: 'Organization',
options: getOrganizationOptions(),
change: (option) => {
props.newLead.organization = option.value
doctype: 'CRM Organization',
change: (data) => (props.newLead.organization = data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
},
{

View File

@ -5,8 +5,8 @@
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex h-7 w-full items-center justify-between gap-2 rounded bg-gray-100 px-2 py-1 transition-colors hover:bg-gray-200 focus:ring-2 focus:ring-gray-400"
:class="{ 'bg-gray-200': isComboboxOpen }"
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
>
<div class="flex items-center">
@ -32,8 +32,8 @@
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white text-base shadow-2xl">
<div class="relative p-1.5 pb-0">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
@ -98,7 +98,7 @@
No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5">
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
@ -121,7 +121,36 @@ import {
import { Popover, Button, FeatherIcon } from 'frappe-ui'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
const props = defineProps(['modelValue', 'options', 'placeholder'])
const props = defineProps({
modelValue: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
size: {
type: String,
default: 'md',
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
filterable: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
const query = ref('')
@ -163,7 +192,7 @@ const groups = computed(() => {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: filterOptions(group.items),
items: props.filterable ? filterOptions(group.items) : group.items,
}
})
.filter((group) => group.items.length > 0)
@ -201,4 +230,46 @@ watch(showOptions, (val) => {
})
}
})
const textColor = computed(() => {
return props.disabled ? 'text-gray-600' : 'text-gray-800'
})
const inputClasses = computed(() => {
let sizeClasses = {
sm: 'text-base rounded h-7',
md: 'text-base rounded h-8',
lg: 'text-lg rounded-md h-10',
xl: 'text-xl rounded-md h-10',
}[props.size]
let paddingClasses = {
sm: 'py-1.5 px-2',
md: 'py-1.5 px-2.5',
lg: 'py-1.5 px-3',
xl: 'py-1.5 px-3',
}[props.size]
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
subtle:
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
outline:
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
disabled: [
'border bg-gray-50 placeholder-gray-400',
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
],
}[variant]
return [
sizeClasses,
paddingClasses,
variantClasses,
textColor.value,
'transition-colors w-full',
]
})
defineExpose({ query })
</script>

View File

@ -0,0 +1,157 @@
<template>
<Menu as="div" class="relative inline-block text-left" v-slot="{ open }">
<Popover
:transition="dropdownTransition"
:show="open"
:placement="popoverPlacement"
>
<template #target>
<MenuButton as="div">
<slot v-if="$slots.default" v-bind="{ open }" />
<Button v-else :active="open" v-bind="button">
{{ button ? button?.label || null : 'Options' }}
</Button>
</MenuButton>
</template>
<template #body>
<div
class="rounded-lg bg-white shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<MenuItems
class="mt-2 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',
}"
>
<div v-for="group in groups" :key="group.key" class="p-1.5">
<div
v-if="group.group && !group.hideLabel"
class="flex h-7 items-center px-2 text-sm font-medium text-gray-500"
>
{{ group.group }}
</div>
<MenuItem
v-for="item in group.items"
: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"
/>
<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>
</MenuItem>
</div>
</MenuItems>
<div v-if="slots.footer" class="border-t p-1.5">
<slot name="footer"></slot>
</div>
</div>
</template>
</Popover>
</Menu>
</template>
<script setup>
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import { Popover, Button, FeatherIcon } from 'frappe-ui'
import { computed, useSlots } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
button: {
type: Object,
default: null,
},
options: {
type: Array,
default: () => [],
},
placement: {
type: String,
default: 'left',
},
})
const router = useRouter()
const slots = useSlots()
const dropdownTransition = {
enterActiveClass: 'transition duration-100 ease-out',
enterFromClass: 'transform scale-95 opacity-0',
enterToClass: 'transform scale-100 opacity-100',
leaveActiveClass: 'transition duration-75 ease-in',
leaveFromClass: 'transform scale-100 opacity-100',
leaveToClass: 'transform scale-95 opacity-0',
}
const groups = computed(() => {
let groups = props.options[0]?.group
? props.options
: [{ group: '', items: props.options }]
return groups.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: filterOptions(group.items),
}
})
})
const popoverPlacement = computed(() => {
if (props.placement === 'left') return 'bottom-start'
if (props.placement === 'right') return 'bottom-end'
if (props.placement === 'center') return 'bottom-center'
return 'bottom'
})
function normalizeDropdownItem(option) {
let onClick = option.onClick || null
if (!onClick && option.route && router) {
onClick = () => router.push(option.route)
}
return {
label: option.label,
icon: option.icon,
group: option.group,
component: option.component,
onClick,
}
}
function filterOptions(options) {
return (options || [])
.filter(Boolean)
.filter((option) => (option.condition ? option.condition() : true))
.map((option) => normalizeDropdownItem(option))
}
</script>

View File

@ -95,22 +95,47 @@
{{ field.label }}
</div>
<div class="flex-1 overflow-hidden">
<FormControl
v-if="field.type === 'email'"
type="email"
class="form-control"
:value="contact[field.name]"
@change.stop="updateContact(field.name, $event.target.value)"
:debounce="500"
/>
<FormControl
v-else-if="field.type === 'link'"
type="autocomplete"
:value="contact[field.name]"
<Dropdown
v-if="field.type === 'dropdown' && field.options.length"
:options="field.options"
@change="(e) => field.change(e)"
:placeholder="field.placeholder"
class="form-control show-dropdown-icon w-full flex-1"
>
<template #default="{ open }">
<div
class="dropdown-button flex w-full items-center justify-between gap-2"
>
<Button
:label="contact[field.name]"
class="w-full justify-between truncate"
>
<div class="truncate">{{ contact[field.name] }}</div>
</Button>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</div>
</template>
<template #footer>
<Button
variant="ghost"
class="w-full !justify-start"
label="Create one"
@click="field.create()"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</template>
</Dropdown>
<Link
v-else-if="field.type === 'link'"
class="form-control"
:value="contact[field.name]"
:doctype="field.doctype"
:placeholder="field.placeholder"
@change="(e) => field.change(e)"
/>
<FormControl
v-else
@ -155,12 +180,14 @@
v-if="tab.label === 'Leads' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false }"
/>
<DealsListView
class="mt-4"
v-if="tab.label === 'Deals' && rows.length"
v-else-if="tab.label === 'Deals' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false }"
/>
<div
v-if="!rows.length"
@ -174,28 +201,41 @@
</template>
</Tabs>
</div>
<Dialog v-model="show" :options="dialogOptions">
<template #body-content>
<FormControl
:type="new_field.type"
variant="outline"
v-model="new_field.value"
:placeholder="new_field.placeholder"
/>
</template>
</Dialog>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import {
FormControl,
FeatherIcon,
Breadcrumbs,
Dialog,
Avatar,
FileUploader,
ErrorMessage,
Dropdown,
Tooltip,
Tabs,
call,
createResource,
createListResource,
} from 'frappe-ui'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import DropdownItem from '@/components/DropdownItem.vue'
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
@ -212,10 +252,11 @@ import { usersStore } from '@/stores/users.js'
import { contactsStore } from '@/stores/contacts.js'
import { organizationsStore } from '@/stores/organizations.js'
import { ref, computed, h } from 'vue'
import { useRouter } from 'vue-router'
const { getContactByName, contacts } = contactsStore()
const { getUser } = usersStore()
const { getOrganization, getOrganizationOptions } = organizationsStore()
const { getOrganization } = organizationsStore()
const props = defineProps({
contactId: {
@ -224,6 +265,8 @@ const props = defineProps({
},
})
const router = useRouter()
const contact = computed(() => getContactByName(props.contactId))
const breadcrumbs = computed(() => {
@ -268,6 +311,7 @@ async function deleteContact() {
})
contacts.reload()
close()
router.push({ name: 'Contacts' })
},
},
],
@ -305,7 +349,7 @@ const leads = createListResource({
'modified',
],
filters: {
email: contact.value.email_id,
email: contact.value?.email_id,
converted: 0,
},
orderBy: 'modified desc',
@ -313,26 +357,12 @@ const leads = createListResource({
auto: true,
})
const deals = createListResource({
type: 'list',
doctype: 'CRM Lead',
const deals = createResource({
url: 'crm.api.contact.get_linked_deals',
cache: ['deals', props.contactId],
fields: [
'name',
'organization',
'annual_revenue',
'status',
'email',
'mobile_no',
'lead_owner',
'modified',
],
filters: {
email: contact.value.email_id,
converted: 1,
params: {
contact: props.contactId,
},
orderBy: 'modified desc',
pageLength: 20,
auto: true,
})
@ -394,9 +424,9 @@ function getDealRowObject(deal) {
},
email: deal.email,
mobile_no: deal.mobile_no,
lead_owner: {
label: deal.lead_owner && getUser(deal.lead_owner).full_name,
...(deal.lead_owner && getUser(deal.lead_owner)),
deal_owner: {
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
...(deal.deal_owner && getUser(deal.deal_owner)),
},
modified: {
label: dateFormat(deal.modified, dateTooltipFormat),
@ -470,8 +500,8 @@ const dealColumns = [
width: '11rem',
},
{
label: 'Lead owner',
key: 'lead_owner',
label: 'Deal owner',
key: 'deal_owner',
width: '10rem',
},
{
@ -487,21 +517,11 @@ const details = computed(() => {
label: 'Salutation',
type: 'link',
name: 'salutation',
placeholder: 'Mr./Mrs./Ms.',
options: [
{ label: 'Dr', value: 'Dr' },
{ label: 'Mr', value: 'Mr' },
{ label: 'Mrs', value: 'Mrs' },
{ label: 'Ms', value: 'Ms' },
{ label: 'Mx', value: 'Mx' },
{ label: 'Prof', value: 'Prof' },
{ label: 'Master', value: 'Master' },
{ label: 'Madam', value: 'Madam' },
{ label: 'Miss', value: 'Miss' },
],
change: (data) => {
contact.value.salutation = data.value
updateContact('salutation', data.value)
placeholder: 'Mr./Mrs./Ms...',
doctype: 'Salutation',
change: (value) => {
contact.value.salutation = value
updateContact('salutation', value)
},
},
{
@ -516,35 +536,98 @@ const details = computed(() => {
},
{
label: 'Email',
type: 'email',
name: 'email',
type: 'dropdown',
name: 'email_id',
options: contact.value?.email_ids?.map((email) => {
return {
component: h(DropdownItem, {
value: email.email_id,
selected: email.email_id === contact.value.email_id,
onClick: () => setAsPrimary('email', email.email_id),
}),
}
}),
create: (value) => {
new_field.value = {
type: 'email',
value,
placeholder: 'Add email address',
}
dialogOptions.value = {
title: 'Add email',
actions: [
{
label: 'Add',
variant: 'solid',
onClick: ({ close }) => createNew('email', close),
},
],
}
show.value = true
},
},
{
label: 'Mobile no.',
type: 'phone',
type: 'dropdown',
name: 'mobile_no',
options: contact.value?.phone_nos?.map((phone) => {
return {
component: h(DropdownItem, {
value: phone.phone,
selected: phone.phone === contact.value.mobile_no,
onClick: () => setAsPrimary('mobile_no', phone.phone),
}),
}
}),
create: (value) => {
new_field.value = {
type: 'phone',
value,
placeholder: 'Add mobile no.',
}
dialogOptions.value = {
title: 'Add mobile no.',
actions: [
{
label: 'Add',
variant: 'solid',
onClick: ({ close }) => createNew('phone', close),
},
],
}
show.value = true
},
},
{
label: 'Organization',
type: 'link',
name: 'company_name',
placeholder: 'Select organization',
options: getOrganizationOptions(),
change: (data) => {
contact.value.company_name = data.value
updateContact('company_name', data.value)
doctype: 'CRM Organization',
change: (value) => {
contact.value.company_name = value
updateContact('company_name', value)
},
link: (data) => {
router.push({
name: 'Organization',
params: { organizationId: data.value },
params: { organizationId: data },
})
},
},
]
})
const show = ref(false)
const new_field = ref({})
const dialogOptions = ref({})
function updateContact(fieldname, value) {
if (['mobile_no', 'email_id'].includes(fieldname)) {
details.value.find((d) => d.name === fieldname).create(value)
return
}
createResource({
url: 'frappe.client.set_value',
params: {
@ -572,6 +655,39 @@ function updateContact(fieldname, value) {
},
})
}
async function setAsPrimary(field, value) {
let d = await call('crm.api.contact.set_as_primary', {
contact: props.contactId,
field,
value,
})
if (d) {
contacts.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function createNew(field, close) {
let d = await call('crm.api.contact.create_new', {
contact: props.contactId,
field,
value: new_field.value.value,
})
if (d) {
contacts.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
close()
}
</script>
<style scoped>
@ -596,4 +712,14 @@ function updateContact(fieldname, value) {
color: white;
width: 0;
}
:deep(:has(> .dropdown-button)) {
width: 100%;
}
:deep(.dropdown-button > button > span) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -8,7 +8,7 @@
type="autocomplete"
:options="activeAgents"
:value="getUser(deal.data.deal_owner).full_name"
@change="(option) => updateAssignedAgent(option.email)"
@change="(option) => updateField('deal_owner', option.email)"
placeholder="Deal owner"
>
<template #prefix>
@ -18,7 +18,9 @@
<UserAvatar class="mr-2" :user="option.email" size="sm" />
</template>
</FormControl>
<Dropdown :options="statusDropdownOptions(deal.data, 'deal', updateDeal)">
<Dropdown
:options="statusDropdownOptions(deal.data, 'deal', updateField)"
>
<template #default="{ open }">
<Button :label="deal.data.status">
<template #prefix>
@ -100,16 +102,47 @@
:class="{ 'border-b': i !== detailSections.length - 1 }"
>
<Toggler :is-opened="section.opened" v-slot="{ opened, toggle }">
<div
class="flex max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
@click="toggle()"
>
<FeatherIcon
name="chevron-right"
class="h-4 text-gray-600 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
{{ section.label }}
<div class="flex items-center justify-between">
<div
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
@click="toggle()"
>
<FeatherIcon
name="chevron-right"
class="h-4 text-gray-900 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
{{ section.label }}
</div>
<div v-if="section.contacts" class="pr-2">
<Link
value=""
doctype="Contact"
@change="(e) => addContact(e)"
:onCreate="
(value, close) => {
_contact = {
first_name: value,
company_name: deal.data.organization,
}
showContactModal = true
close()
}
"
>
<template #target="{ togglePopover }">
<Button
class="h-7 px-3"
label="Add contact"
@click="togglePopover()"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</template>
</Link>
</div>
</div>
<transition
enter-active-class="duration-300 ease-in"
@ -121,6 +154,7 @@
>
<div v-if="opened" class="flex flex-col gap-1.5">
<div
v-if="section.fields"
v-for="field in section.fields"
:key="field.label"
class="flex items-center gap-2 px-3 text-base leading-5 first:mt-3"
@ -129,105 +163,15 @@
{{ field.label }}
</div>
<div class="flex-1 overflow-hidden">
<FormControl
v-if="field.type === 'select'"
type="select"
:options="field.options"
:value="deal.data[field.name]"
@change.stop="
updateDeal(field.name, $event.target.value)
"
:debounce="500"
class="form-control cursor-pointer [&_select]:cursor-pointer"
>
<template #prefix>
<IndicatorIcon
:class="dealStatuses[deal.data[field.name]].color"
/>
</template>
</FormControl>
<FormControl
v-else-if="field.type === 'email'"
type="email"
<Link
v-if="field.type === 'link'"
class="form-control"
:value="deal.data[field.name]"
@change.stop="
updateDeal(field.name, $event.target.value)
"
:debounce="500"
/>
<FormControl
v-else-if="field.type === 'link'"
type="autocomplete"
:value="deal.data[field.name]"
:options="field.options"
@change="(e) => field.change(e)"
:doctype="field.doctype"
:placeholder="field.placeholder"
class="form-control"
@change="(e) => field.change(e)"
:onCreate="field.create"
/>
<FormControl
v-else-if="field.type === 'user'"
type="autocomplete"
:options="activeAgents"
:value="getUser(deal.data[field.name]).full_name"
@change="(option) => updateAssignedAgent(option.email)"
class="form-control"
:placeholder="deal.placeholder"
>
<template #target="{ togglePopover }">
<Button
variant="ghost"
@click="togglePopover()"
:label="getUser(deal.data[field.name]).full_name"
class="w-full !justify-start"
>
<template #prefix>
<UserAvatar
:user="deal.data[field.name]"
size="sm"
/>
</template>
</Button>
</template>
<template #item-prefix="{ option }">
<UserAvatar
class="mr-2"
:user="option.email"
size="sm"
/>
</template>
</FormControl>
<Dropdown
v-else-if="field.type === 'dropdown'"
:options="
statusDropdownOptions(deal.data, 'deal', updateDeal)
"
class="w-full flex-1"
>
<template #default="{ open }">
<Button
:label="deal.data[field.name]"
class="w-full justify-between"
>
<template #prefix>
<IndicatorIcon
:class="
dealStatuses[deal.data[field.name]].color
"
/>
</template>
<template #default>{{
deal.data[field.name]
}}</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
<FormControl
v-else-if="field.type === 'date'"
type="date"
@ -238,26 +182,6 @@
:debounce="500"
class="form-control"
/>
<FormControl
v-else-if="field.type === 'number'"
type="number"
:value="deal.data[field.name]"
@change.stop="
updateDeal(field.name, $event.target.value)
"
:debounce="500"
class="form-control"
/>
<FormControl
v-else-if="field.type === 'tel'"
type="tel"
:value="deal.data[field.name]"
@change.stop="
updateDeal(field.name, $event.target.value)
"
:debounce="500"
class="form-control"
/>
<Tooltip
:text="field.tooltip"
class="flex h-7 cursor-pointer items-center px-2 py-1"
@ -286,6 +210,110 @@
@click="field.link(deal.data[field.name])"
/>
</div>
<div v-else>
<div
v-if="section.contacts.length"
v-for="(contact, i) in section.contacts"
:key="contact.name"
>
<div
class="px-2 pb-2.5"
:class="[i == 0 ? 'pt-5' : 'pt-2.5']"
>
<Toggler
:is-opened="contact.opened"
v-slot="{ opened: cOpened, toggle: cToggle }"
>
<div
class="flex cursor-pointer items-center justify-between gap-2 pr-1 text-base leading-5 text-gray-700"
>
<div
class="flex h-7 items-center gap-2"
@click="cToggle()"
>
<Avatar
:label="
getContactByName(contact.name).full_name
"
:image="getContactByName(contact.name).image"
size="md"
/>
{{ getContactByName(contact.name).full_name }}
<Badge
v-if="contact.is_primary"
class="ml-2"
variant="outline"
label="Primary"
theme="green"
/>
</div>
<div class="flex items-center">
<Dropdown :options="contactOptions(contact)">
<Button variant="ghost">
<FeatherIcon
name="more-horizontal"
class="h-4 text-gray-600"
/>
</Button>
</Dropdown>
<Button
variant="ghost"
@click="
router.push({
name: 'Contact',
params: { contactId: contact.name },
})
"
>
<ExternalLinkIcon class="h-4 w-4" />
</Button>
<Button variant="ghost" @click="cToggle()">
<FeatherIcon
name="chevron-right"
class="h-4 w-4 text-gray-900 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': cOpened }"
/>
</Button>
</div>
</div>
<transition
enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
enter-to-class="max-h-[200px] overflow-hidden"
leave-from-class="max-h-[200px] overflow-hidden"
enter-from-class="max-h-0 overflow-hidden"
leave-to-class="max-h-0 overflow-hidden"
>
<div
v-if="cOpened"
class="flex flex-col gap-1.5 text-base text-gray-800"
>
<div
class="flex items-center gap-3 pb-1.5 pl-1 pt-4"
>
<EmailIcon class="h-4 w-4" />
{{ getContactByName(contact.name).email_id }}
</div>
<div class="flex items-center gap-3 p-1 py-1.5">
<PhoneIcon class="h-4 w-4" />
{{ getContactByName(contact.name).mobile_no }}
</div>
</div>
</transition>
</Toggler>
</div>
<div
v-if="i != section.contacts.length - 1"
class="mx-2 h-px border-t border-gray-200"
/>
</div>
<div
v-else
class="flex h-20 items-center justify-center text-base text-gray-600"
>
No contacts added
</div>
</div>
</div>
</transition>
</Toggler>
@ -294,6 +322,25 @@
</div>
</div>
</div>
<OrganizationModal
v-model="showOrganizationModal"
:organization="_organization"
:options="{
redirect: false,
afterInsert: (doc) =>
updateField('organization', doc.name, () => {
organizations.reload()
}),
}"
/>
<ContactModal
v-model="showContactModal"
:contact="_contact"
:options="{
redirect: false,
afterInsert: (doc) => addContact(doc.name),
}"
/>
</template>
<script setup>
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
@ -304,10 +351,14 @@ import NoteIcon from '@/components/Icons/NoteIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import LinkIcon from '@/components/Icons/LinkIcon.vue'
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Toggler from '@/components/Toggler.vue'
import Activities from '@/components/Activities.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import Link from '@/components/Controls/Link.vue'
import {
dealStatuses,
statusDropdownOptions,
@ -327,13 +378,15 @@ import {
Avatar,
Tabs,
Breadcrumbs,
call,
Badge,
} from 'frappe-ui'
import { ref, computed } from 'vue'
import { ref, computed, h } from 'vue'
import { useRouter } from 'vue-router'
const { getUser } = usersStore()
const { contacts } = contactsStore()
const { getOrganization, getOrganizationOptions } = organizationsStore()
const { getContactByName, contacts } = contactsStore()
const { organizations, getOrganization } = organizationsStore()
const router = useRouter()
const props = defineProps({
@ -351,8 +404,12 @@ const deal = createResource({
})
const reload = ref(false)
const showOrganizationModal = ref(false)
const _organization = ref({})
function updateDeal(fieldname, value, callback) {
value = Array.isArray(fieldname) ? '' : value
function updateDeal(fieldname, value) {
createResource({
url: 'frappe.client.set_value',
params: {
@ -371,6 +428,7 @@ function updateDeal(fieldname, value) {
icon: 'check',
iconClasses: 'text-green-600',
})
callback?.()
},
onError: (err) => {
createToast({
@ -427,10 +485,12 @@ const detailSections = computed(() => {
type: 'link',
name: 'organization',
placeholder: 'Select organization',
options: getOrganizationOptions(),
change: (data) => {
deal.data.organization = data.value
updateDeal('organization', data.value)
doctype: 'CRM Organization',
change: (data) => updateField('organization', data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
link: () => {
router.push({
@ -475,66 +535,102 @@ const detailSections = computed(() => {
{
label: 'Contacts',
opened: true,
fields: [
{
label: 'Salutation',
type: 'link',
name: 'salutation',
placeholder: 'Mr./Mrs./Ms.',
options: [
{ label: 'Dr', value: 'Dr' },
{ label: 'Mr', value: 'Mr' },
{ label: 'Mrs', value: 'Mrs' },
{ label: 'Ms', value: 'Ms' },
{ label: 'Mx', value: 'Mx' },
{ label: 'Prof', value: 'Prof' },
{ label: 'Master', value: 'Master' },
{ label: 'Madam', value: 'Madam' },
{ label: 'Miss', value: 'Miss' },
],
change: (data) => {
deal.data.salutation = data.value
updateDeal('salutation', data.value)
},
},
{
label: 'First name',
type: 'data',
name: 'first_name',
},
{
label: 'Last name',
type: 'data',
name: 'last_name',
},
{
label: 'Email',
type: 'email',
name: 'email',
},
{
label: 'Mobile no.',
type: 'tel',
name: 'mobile_no',
},
],
contacts: deal.data.contacts.map((contact) => {
return {
name: contact.contact,
is_primary: contact.is_primary,
opened: false,
}
}),
},
]
})
const showContactModal = ref(false)
const _contact = ref({})
function contactOptions(contact) {
let options = [
{
label: 'Delete',
icon: 'trash-2',
onClick: () => removeContact(contact.name),
},
]
if (!contact.is_primary) {
options.push({
label: 'Set as primary contact',
icon: h(SuccessIcon, { class: 'h-4 w-4' }),
onClick: () => setPrimaryContact(contact.name),
})
}
return options
}
async function addContact(contact) {
let d = await call('crm.fcrm.doctype.crm_deal.crm_deal.add_contact', {
deal: props.dealId,
contact,
})
if (d) {
await contacts.reload()
deal.reload()
createToast({
title: 'Contact added',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function removeContact(contact) {
let d = await call('crm.fcrm.doctype.crm_deal.crm_deal.remove_contact', {
deal: props.dealId,
contact,
})
if (d) {
deal.reload()
contacts.reload()
createToast({
title: 'Contact removed',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function setPrimaryContact(contact) {
let d = await call('crm.fcrm.doctype.crm_deal.crm_deal.set_primary_contact', {
deal: props.dealId,
contact,
})
if (d) {
await contacts.reload()
deal.reload()
createToast({
title: 'Primary contact set',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
const organization = computed(() => {
return getOrganization(deal.data.organization)
})
function updateAssignedAgent(email) {
deal.data.deal_owner = email
updateDeal('deal_owner', email)
function updateField(name, value, callback) {
updateDeal(name, value, () => {
deal.data[name] = value
callback?.()
})
}
</script>
<style scoped>
:deep(.form-control input),
:deep(.form-control select),
:deep(.form-control button) {
border-color: transparent;
background: white;

View File

@ -18,7 +18,9 @@
<UserAvatar class="mr-2" :user="option.email" size="sm" />
</template>
</FormControl>
<Dropdown :options="statusDropdownOptions(lead.data, 'lead', updateLead)">
<Dropdown
:options="statusDropdownOptions(lead.data, 'lead', updateField)"
>
<template #default="{ open }">
<Button :label="lead.data.status">
<template #prefix>
@ -32,11 +34,7 @@
</Button>
</template>
</Dropdown>
<Button
label="Convert to deal"
variant="solid"
@click="convertToDeal()"
/>
<Button label="Convert to deal" variant="solid" @click="convertToDeal" />
</template>
</LayoutHeader>
<div v-if="lead?.data" class="flex h-full overflow-hidden">
@ -54,7 +52,10 @@
>
About this lead
</div>
<FileUploader @success="changeLeadImage" :validateFile="validateFile">
<FileUploader
@success="(file) => updateField('image', file.file_url)"
:validateFile="validateFile"
>
<template #default="{ openFileSelector, error }">
<div class="flex items-center justify-start gap-5 p-5">
<div class="group relative h-[88px] w-[88px]">
@ -80,7 +81,7 @@
{
icon: 'trash-2',
label: 'Remove image',
onClick: () => changeLeadImage(''),
onClick: () => updateField('image', ''),
},
],
}
@ -197,29 +198,15 @@
"
:debounce="500"
/>
<Autocomplete
<Link
v-else-if="field.type === 'link'"
:value="lead.data[field.name]"
:options="field.options"
@change="(e) => field.change(e)"
:placeholder="field.placeholder"
class="form-control"
>
<template #footer="{ value, close }">
<div>
<Button
variant="ghost"
class="w-full !justify-start"
label="Create one"
@click="field.create(value, close)"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</template>
</Autocomplete>
:value="lead.data[field.name]"
:doctype="field.doctype"
:placeholder="field.placeholder"
@change="(e) => field.change(e)"
:onCreate="field.create"
/>
<FormControl
v-else-if="field.type === 'user'"
type="autocomplete"
@ -254,37 +241,6 @@
/>
</template>
</FormControl>
<Dropdown
v-else-if="field.type === 'dropdown'"
:options="
statusDropdownOptions(lead.data, 'lead', updateLead)
"
class="w-full flex-1"
>
<template #default="{ open }">
<Button
:label="lead.data[field.name]"
class="w-full justify-between"
>
<template #prefix>
<IndicatorIcon
:class="
leadStatuses[lead.data[field.name]].color
"
/>
</template>
<template #default>{{
lead.data[field.name]
}}</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
<Tooltip
:text="field.tooltip"
class="flex h-7 cursor-pointer items-center px-2 py-1"
@ -326,7 +282,10 @@
:organization="_organization"
:options="{
redirect: false,
afterInsert: (doc) => updateField('organiation', doc.name),
afterInsert: (doc) =>
updateField('organization', doc.name, () => {
organizations.reload()
}),
}"
/>
</template>
@ -345,7 +304,7 @@ import Toggler from '@/components/Toggler.vue'
import Activities from '@/components/Activities.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import Link from '@/components/Controls/Link.vue'
import {
leadStatuses,
statusDropdownOptions,
@ -374,7 +333,7 @@ import { useRouter } from 'vue-router'
const { getUser } = usersStore()
const { contacts } = contactsStore()
const { getOrganization, getOrganizationOptions } = organizationsStore()
const { organizations, getOrganization } = organizationsStore()
const router = useRouter()
const props = defineProps({
@ -395,7 +354,9 @@ const reload = ref(false)
const showOrganizationModal = ref(false)
const _organization = ref({})
function updateLead(fieldname, value) {
function updateLead(fieldname, value, callback) {
value = Array.isArray(fieldname) ? '' : value
createResource({
url: 'frappe.client.set_value',
params: {
@ -407,13 +368,13 @@ function updateLead(fieldname, value) {
auto: true,
onSuccess: () => {
lead.reload()
contacts.reload()
reload.value = true
createToast({
title: 'Lead updated',
icon: 'check',
iconClasses: 'text-green-600',
})
callback?.()
},
onError: (err) => {
createToast({
@ -459,11 +420,6 @@ const tabs = [
},
]
function changeLeadImage(file) {
lead.data.image = file.file_url
updateLead('image', file.file_url)
}
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
@ -482,19 +438,18 @@ const detailSections = computed(() => {
type: 'link',
name: 'organization',
placeholder: 'Select organization',
options: getOrganizationOptions(),
change: (data) => data && updateField('organization', data.value),
doctype: 'CRM Organization',
change: (data) => data && updateField('organization', data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
link: () => {
link: () =>
router.push({
name: 'Organization',
params: { organizationId: organization.value?.name },
})
},
params: { organizationId: lead.data.organization },
}),
},
{
label: 'Website',
@ -504,6 +459,14 @@ const detailSections = computed(() => {
tooltip:
'It is a read only field, value is fetched from organization',
},
{
label: 'Industry',
type: 'read_only',
name: 'industry',
value: organization.value?.industry,
tooltip:
'It is a read only field, value is fetched from organization',
},
{
label: 'Job title',
type: 'data',
@ -514,31 +477,8 @@ const detailSections = computed(() => {
type: 'link',
name: 'source',
placeholder: 'Select source...',
options: [
{ label: 'Advertisement', value: 'Advertisement' },
{ label: 'Web', value: 'Web' },
{ label: 'Others', value: 'Others' },
],
change: (data) => {
lead.data.source = data.value
updateLead('source', data.value)
},
},
{
label: 'Industry',
type: 'link',
name: 'industry',
placeholder: 'Select industry...',
options: [
{ label: 'Advertising', value: 'Advertising' },
{ label: 'Agriculture', value: 'Agriculture' },
{ label: 'Banking', value: 'Banking' },
{ label: 'Others', value: 'Others' },
],
change: (data) => {
lead.data.industry = data.value
updateLead('industry', data.value)
},
doctype: 'CRM Lead Source',
change: (data) => updateField('source', data),
},
],
},
@ -550,22 +490,9 @@ const detailSections = computed(() => {
label: 'Salutation',
type: 'link',
name: 'salutation',
placeholder: 'Mr./Mrs./Ms.',
options: [
{ label: 'Dr', value: 'Dr' },
{ label: 'Mr', value: 'Mr' },
{ label: 'Mrs', value: 'Mrs' },
{ label: 'Ms', value: 'Ms' },
{ label: 'Mx', value: 'Mx' },
{ label: 'Prof', value: 'Prof' },
{ label: 'Master', value: 'Master' },
{ label: 'Madam', value: 'Madam' },
{ label: 'Miss', value: 'Miss' },
],
change: (data) => {
lead.data.salutation = data.value
updateLead('salutation', data.value)
},
placeholder: 'Mr./Mrs./Ms...',
doctype: 'Salutation',
change: (data) => updateField('salutation', data),
},
{
label: 'First name',
@ -596,30 +523,21 @@ const organization = computed(() => {
return getOrganization(lead.data.organization)
})
function convertToDeal() {
lead.data.status = 'Qualified'
lead.data.converted = 1
createDeal(lead.data)
}
async function createDeal(lead) {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Deal',
organization: lead.organization,
email: lead.email,
mobile_no: lead.mobile_no,
lead: lead.name,
},
async function convertToDeal() {
let deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
lead: lead.data.name,
})
if (d.name) {
router.push({ name: 'Deal', params: { dealId: d.name } })
if (deal) {
await contacts.reload()
router.push({ name: 'Deal', params: { dealId: deal } })
}
}
function updateField(name, value) {
lead.data[name] = value
updateLead(name, value)
function updateField(name, value, callback) {
updateLead(name, value, () => {
lead.data[name] = value
callback?.()
})
}
</script>

View File

@ -61,25 +61,30 @@
<span class="">{{ website(organization.website) }}</span>
</div>
<span
v-if="organization.email_id"
v-if="organization.industry && organization.website"
class="text-3xl leading-[0] text-gray-600"
>&middot;</span
>
<div v-if="organization.email_id" class="flex items-center gap-1.5">
<EmailIcon class="h-4 w-4" />
<span class="">{{ organization.email_id }}</span>
&middot;
</span>
<div v-if="organization.industry" class="flex items-center gap-1.5">
<FeatherIcon name="briefcase" class="h-4 w-4" />
<span class="">{{ organization.industry }}</span>
</div>
<span
v-if="
(organization.name || organization.email_id) &&
organization.mobile_no
(organization.website || organization.industry) &&
organization.annual_revenue
"
class="text-3xl leading-[0] text-gray-600"
>&middot;</span
>
<div v-if="organization.mobile_no" class="flex items-center gap-1.5">
<PhoneIcon class="h-4 w-4" />
<span class="">{{ organization.mobile_no }}</span>
&middot;
</span>
<div
v-if="organization.annual_revenue"
class="flex items-center gap-1.5"
>
<FeatherIcon name="dollar-sign" class="h-4 w-4" />
<span class="">{{ organization.annual_revenue }}</span>
</div>
</div>
<div class="mt-1 flex gap-2">
@ -137,18 +142,21 @@
v-if="tab.label === 'Leads' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false }"
/>
<DealsListView
class="mt-4"
v-if="tab.label === 'Deals' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false }"
/>
<ContactsListView
class="mt-4"
v-if="tab.label === 'Contacts' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false }"
/>
<div
v-if="!rows.length"
@ -307,7 +315,7 @@ const leads = createListResource({
const deals = createListResource({
type: 'list',
doctype: 'CRM Lead',
doctype: 'CRM Deal',
cache: ['deals', props.organization.name],
fields: [
'name',
@ -316,12 +324,11 @@ const deals = createListResource({
'status',
'email',
'mobile_no',
'lead_owner',
'deal_owner',
'modified',
],
filters: {
organization: props.organization.name,
converted: 1,
},
orderBy: 'modified desc',
pageLength: 20,
@ -415,9 +422,9 @@ function getDealRowObject(deal) {
},
email: deal.email,
mobile_no: deal.mobile_no,
lead_owner: {
label: deal.lead_owner && getUser(deal.lead_owner).full_name,
...(deal.lead_owner && getUser(deal.lead_owner)),
deal_owner: {
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
...(deal.deal_owner && getUser(deal.deal_owner)),
},
modified: {
label: dateFormat(deal.modified, dateTooltipFormat),
@ -512,8 +519,8 @@ const dealColumns = [
width: '11rem',
},
{
label: 'Lead owner',
key: 'lead_owner',
label: 'Deal owner',
key: 'deal_owner',
width: '10rem',
},
{

View File

@ -78,7 +78,6 @@ export function statusDropdownOptions(data, doctype, action) {
label: statuses[status].label,
icon: () => h(IndicatorIcon, { class: statuses[status].color }),
onClick: () => {
data.status = statuses[status].label
action && action('status', statuses[status].label)
},
})