Merge pull request #26 from shariquerik/contact-linking
feat: Contact linking
This commit is contained in:
commit
6180899a3b
107
crm/api/contact.py
Normal file
107
crm/api/contact.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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",
|
||||
|
||||
12
crm/hooks.py
12
crm/hooks.py
@ -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
|
||||
# ---------------
|
||||
|
||||
124
frontend/src/components/Controls/Link.vue
Normal file
124
frontend/src/components/Controls/Link.vue
Normal 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>
|
||||
41
frontend/src/components/DropdownItem.vue
Normal file
41
frontend/src/components/DropdownItem.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/SuccessIcon.vue
Normal file
16
frontend/src/components/Icons/SuccessIcon.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -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()
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
157
frontend/src/components/frappe-ui/Dropdown.vue
Normal file
157
frontend/src/components/frappe-ui/Dropdown.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"
|
||||
>·</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>
|
||||
·
|
||||
</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"
|
||||
>·</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>
|
||||
·
|
||||
</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',
|
||||
},
|
||||
{
|
||||
|
||||
@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user