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":
|
if frappe.session.user == "Guest":
|
||||||
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
|
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
|
||||||
|
|
||||||
contacts = frappe.qb.get_query(
|
contacts = frappe.get_all(
|
||||||
"Contact",
|
"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",
|
order_by="first_name asc",
|
||||||
distinct=True,
|
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
|
return contacts
|
||||||
|
|
||||||
@ -39,7 +64,7 @@ def get_organizations():
|
|||||||
|
|
||||||
organizations = frappe.qb.get_query(
|
organizations = frappe.qb.get_query(
|
||||||
"CRM Organization",
|
"CRM Organization",
|
||||||
fields=['name', 'organization_name', 'organization_logo', 'website'],
|
fields=['*'],
|
||||||
order_by="name asc",
|
order_by="name asc",
|
||||||
distinct=True,
|
distinct=True,
|
||||||
).run(as_dict=1)
|
).run(as_dict=1)
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"column_break_uvny",
|
"column_break_uvny",
|
||||||
"gender",
|
"gender",
|
||||||
"mobile_no",
|
"mobile_no",
|
||||||
"phone"
|
"phone",
|
||||||
|
"is_primary"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -55,7 +56,6 @@
|
|||||||
"fetch_from": "contact.phone",
|
"fetch_from": "contact.phone",
|
||||||
"fieldname": "phone",
|
"fieldname": "phone",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Phone",
|
"label": "Phone",
|
||||||
"options": "Phone",
|
"options": "Phone",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
@ -64,16 +64,22 @@
|
|||||||
"fetch_from": "contact.gender",
|
"fetch_from": "contact.gender",
|
||||||
"fieldname": "gender",
|
"fieldname": "gender",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Gender",
|
"label": "Gender",
|
||||||
"options": "Gender",
|
"options": "Gender",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "is_primary",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Is Primary"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-08-25 19:19:27.813526",
|
"modified": "2023-11-12 14:58:18.846919",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Contacts",
|
"name": "CRM Contacts",
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
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()
|
@frappe.whitelist()
|
||||||
@ -22,4 +18,11 @@ def get_deal(name):
|
|||||||
frappe.throw(_("Deal not found"), frappe.DoesNotExistError)
|
frappe.throw(_("Deal not found"), frappe.DoesNotExistError)
|
||||||
deal = deal.pop()
|
deal = deal.pop()
|
||||||
|
|
||||||
|
|
||||||
|
deal["contacts"] = frappe.get_all(
|
||||||
|
"CRM Contacts",
|
||||||
|
filters={"parenttype": "CRM Deal", "parent": deal.name},
|
||||||
|
fields=["contact", "is_primary"],
|
||||||
|
)
|
||||||
|
|
||||||
return deal
|
return deal
|
||||||
|
|||||||
@ -21,7 +21,8 @@
|
|||||||
"column_break_bqvs",
|
"column_break_bqvs",
|
||||||
"contacts_tab",
|
"contacts_tab",
|
||||||
"email",
|
"email",
|
||||||
"mobile_no"
|
"mobile_no",
|
||||||
|
"contacts"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -114,11 +115,17 @@
|
|||||||
"options": "Qualification\nDemo/Making\nProposal/Quotation\nNegotiation\nReady to Close\nWon\nLost",
|
"options": "Qualification\nDemo/Making\nProposal/Quotation\nNegotiation\nReady to Close\nWon\nLost",
|
||||||
"reqd": 1,
|
"reqd": 1,
|
||||||
"search_index": 1
|
"search_index": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "contacts",
|
||||||
|
"fieldtype": "Table",
|
||||||
|
"label": "Contacts",
|
||||||
|
"options": "CRM Contacts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-11-06 21:53:50.442404",
|
"modified": "2023-11-09 19:58:15.620483",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal",
|
"name": "CRM Deal",
|
||||||
|
|||||||
@ -1,11 +1,50 @@
|
|||||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
# import frappe
|
import frappe
|
||||||
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
class CRMDeal(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
|
@staticmethod
|
||||||
def sort_options():
|
def sort_options():
|
||||||
return [
|
return [
|
||||||
@ -17,3 +56,33 @@ class CRMDeal(Document):
|
|||||||
{ "label": 'Email', "value": 'email' },
|
{ "label": 'Email', "value": 'email' },
|
||||||
{ "label": 'Mobile no', "value": 'mobile_no' },
|
{ "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",
|
"column_break_lcuv",
|
||||||
"lead_owner",
|
"lead_owner",
|
||||||
"status",
|
"status",
|
||||||
|
"job_title",
|
||||||
"source",
|
"source",
|
||||||
"converted",
|
"converted",
|
||||||
"organization_tab",
|
"organization_tab",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"no_of_employees",
|
"no_of_employees",
|
||||||
"column_break_dbsv",
|
"column_break_dbsv",
|
||||||
"website",
|
"website",
|
||||||
"job_title",
|
|
||||||
"annual_revenue",
|
"annual_revenue",
|
||||||
"industry",
|
"industry",
|
||||||
"contact_tab",
|
"contact_tab",
|
||||||
@ -37,9 +37,7 @@
|
|||||||
"column_break_sijm",
|
"column_break_sijm",
|
||||||
"mobile_no",
|
"mobile_no",
|
||||||
"column_break_sjtw",
|
"column_break_sjtw",
|
||||||
"phone",
|
"phone"
|
||||||
"section_break_jyxr",
|
|
||||||
"contacts"
|
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -187,11 +185,9 @@
|
|||||||
"search_index": 1
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fetch_from": "organization.job_title",
|
|
||||||
"fieldname": "job_title",
|
"fieldname": "job_title",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Job Title",
|
"label": "Job Title"
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "organization_tab",
|
"fieldname": "organization_tab",
|
||||||
@ -203,16 +199,6 @@
|
|||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
"label": "Contact"
|
"label": "Contact"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "contacts",
|
|
||||||
"fieldtype": "Table",
|
|
||||||
"label": "Contacts",
|
|
||||||
"options": "CRM Contacts"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "section_break_jyxr",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "organization",
|
"fieldname": "organization",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
@ -231,7 +217,7 @@
|
|||||||
"image_field": "image",
|
"image_field": "image",
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-11-06 21:53:32.542503",
|
"modified": "2023-11-13 13:35:35.783003",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Lead",
|
"name": "CRM Lead",
|
||||||
|
|||||||
@ -14,9 +14,7 @@ class CRMLead(Document):
|
|||||||
self.set_lead_name()
|
self.set_lead_name()
|
||||||
self.set_title()
|
self.set_title()
|
||||||
self.validate_email()
|
self.validate_email()
|
||||||
if not self.is_new():
|
|
||||||
self.validate_contact()
|
|
||||||
|
|
||||||
def set_full_name(self):
|
def set_full_name(self):
|
||||||
if self.first_name:
|
if self.first_name:
|
||||||
self.lead_name = " ".join(
|
self.lead_name = " ".join(
|
||||||
@ -37,7 +35,7 @@ class CRMLead(Document):
|
|||||||
|
|
||||||
def set_title(self):
|
def set_title(self):
|
||||||
self.title = self.organization or self.lead_name
|
self.title = self.organization or self.lead_name
|
||||||
|
|
||||||
def validate_email(self):
|
def validate_email(self):
|
||||||
if self.email:
|
if self.email:
|
||||||
if not self.flags.ignore_email_validation:
|
if not self.flags.ignore_email_validation:
|
||||||
@ -48,58 +46,15 @@ class CRMLead(Document):
|
|||||||
|
|
||||||
if self.is_new() or not self.image:
|
if self.is_new() or not self.image:
|
||||||
self.image = has_gravatar(self.email)
|
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):
|
def create_contact(self):
|
||||||
if not self.lead_name:
|
if not self.lead_name:
|
||||||
self.set_full_name()
|
self.set_full_name()
|
||||||
self.set_lead_name()
|
self.set_lead_name()
|
||||||
|
|
||||||
|
if self.contact_exists():
|
||||||
|
return
|
||||||
|
|
||||||
contact = frappe.new_doc("Contact")
|
contact = frappe.new_doc("Contact")
|
||||||
contact.update(
|
contact.update(
|
||||||
{
|
{
|
||||||
@ -125,7 +80,40 @@ class CRMLead(Document):
|
|||||||
contact.insert(ignore_permissions=True)
|
contact.insert(ignore_permissions=True)
|
||||||
contact.reload() # load changes by hooks on contact
|
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
|
@staticmethod
|
||||||
def sort_options():
|
def sort_options():
|
||||||
@ -140,4 +128,17 @@ class CRMLead(Document):
|
|||||||
{ "label": 'Last Name', "value": 'last_name' },
|
{ "label": 'Last Name', "value": 'last_name' },
|
||||||
{ "label": 'Email', "value": 'email' },
|
{ "label": 'Email', "value": 'email' },
|
||||||
{ "label": 'Mobile no', "value": 'mobile_no' },
|
{ "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",
|
"organization_logo",
|
||||||
"column_break_pnpp",
|
"column_break_pnpp",
|
||||||
"website",
|
"website",
|
||||||
"job_title",
|
|
||||||
"annual_revenue",
|
"annual_revenue",
|
||||||
"industry"
|
"industry"
|
||||||
],
|
],
|
||||||
@ -43,11 +42,6 @@
|
|||||||
"fieldname": "column_break_pnpp",
|
"fieldname": "column_break_pnpp",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "job_title",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"label": "Job Title"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "annual_revenue",
|
"fieldname": "annual_revenue",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
@ -63,7 +57,7 @@
|
|||||||
"image_field": "organization_logo",
|
"image_field": "organization_logo",
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-11-06 15:28:26.610882",
|
"modified": "2023-11-13 13:32:39.029742",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Organization",
|
"name": "CRM Organization",
|
||||||
|
|||||||
12
crm/hooks.py
12
crm/hooks.py
@ -125,13 +125,11 @@ website_route_rules = [
|
|||||||
# ---------------
|
# ---------------
|
||||||
# Hook on document methods and events
|
# Hook on document methods and events
|
||||||
|
|
||||||
# doc_events = {
|
doc_events = {
|
||||||
# "*": {
|
"Contact": {
|
||||||
# "on_update": "method",
|
"validate": ["crm.api.contact.validate"],
|
||||||
# "on_cancel": "method",
|
},
|
||||||
# "on_trash": "method"
|
}
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
||||||
# Scheduled Tasks
|
# 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"
|
:rows="rows"
|
||||||
:options="{
|
:options="{
|
||||||
getRowRoute: (row) => ({ name: 'Contact', params: { contactId: row.name } }),
|
getRowRoute: (row) => ({ name: 'Contact', params: { contactId: row.name } }),
|
||||||
|
selectable: options.selectable,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
>
|
>
|
||||||
@ -70,5 +71,11 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
selectable: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
:rows="rows"
|
:rows="rows"
|
||||||
:options="{
|
:options="{
|
||||||
getRowRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }),
|
getRowRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }),
|
||||||
|
selectable: options.selectable,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
>
|
>
|
||||||
@ -75,5 +76,11 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
selectable: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
:rows="rows"
|
:rows="rows"
|
||||||
:options="{
|
:options="{
|
||||||
getRowRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }),
|
getRowRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }),
|
||||||
|
selectable: options.selectable,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
>
|
>
|
||||||
@ -84,5 +85,11 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
selectable: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,19 +9,21 @@
|
|||||||
label: editMode ? 'Update' : 'Create',
|
label: editMode ? 'Update' : 'Create',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
disabled: !dirty,
|
disabled: !dirty,
|
||||||
onClick: ({ close }) => updateContact(close),
|
onClick: ({ close }) =>
|
||||||
|
editMode ? updateContact(close) : callInsertDoc(close),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<FormControl
|
<Link
|
||||||
type="text"
|
|
||||||
size="md"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="md"
|
||||||
label="Salutation"
|
label="Salutation"
|
||||||
v-model="_contact.salutation"
|
v-model="_contact.salutation"
|
||||||
|
doctype="Salutation"
|
||||||
|
placeholder="Mr./Mrs./Ms..."
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
@ -41,12 +43,13 @@
|
|||||||
v-model="_contact.last_name"
|
v-model="_contact.last_name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<Link
|
||||||
type="text"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="md"
|
size="md"
|
||||||
label="Organisation"
|
label="Organization"
|
||||||
v-model="_contact.company_name"
|
v-model="_contact.company_name"
|
||||||
|
doctype="CRM Organization"
|
||||||
|
placeholder="Select organization"
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
@ -72,16 +75,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FormControl, Dialog, call } from 'frappe-ui'
|
import { FormControl, Dialog, call } from 'frappe-ui'
|
||||||
import { ref, defineModel, nextTick, watch, computed } from 'vue'
|
import { ref, defineModel, nextTick, watch, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contact: {
|
contact: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: {},
|
default: {},
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: {
|
||||||
|
redirect: true,
|
||||||
|
afterInsert: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const contacts = defineModel('reloadContacts')
|
const contacts = defineModel('reloadContacts')
|
||||||
|
|
||||||
@ -89,31 +102,55 @@ const editMode = ref(false)
|
|||||||
let _contact = ref({})
|
let _contact = ref({})
|
||||||
|
|
||||||
async function updateContact(close) {
|
async function updateContact(close) {
|
||||||
if (JSON.stringify(props.contact) === JSON.stringify(_contact.value)) {
|
if (!dirty.value) {
|
||||||
|
close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_contact.value.name) {
|
let name = await callSetValue(values)
|
||||||
let d = await call('frappe.client.set_value', {
|
|
||||||
doctype: 'Contact',
|
handleContactUpdate({ name }, close)
|
||||||
name: _contact.value.name,
|
}
|
||||||
fieldname: _contact.value,
|
|
||||||
})
|
async function callSetValue(values) {
|
||||||
if (d.name) {
|
const d = await call('frappe.client.set_value', {
|
||||||
contacts.value.reload()
|
doctype: 'Contact',
|
||||||
}
|
name: _contact.value.name,
|
||||||
} else {
|
fieldname: values,
|
||||||
let d = await call('frappe.client.insert', {
|
})
|
||||||
doc: {
|
return d.name
|
||||||
doctype: 'Contact',
|
}
|
||||||
..._contact.value,
|
|
||||||
},
|
async function callInsertDoc(close) {
|
||||||
})
|
if (_contact.value.email_id) {
|
||||||
if (d.name) {
|
_contact.value.email_ids = [{ email_id: _contact.value.email_id }]
|
||||||
contacts.value.reload()
|
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(() => {
|
const dirty = computed(() => {
|
||||||
@ -127,7 +164,7 @@ watch(
|
|||||||
editMode.value = false
|
editMode.value = false
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
_contact.value = { ...props.contact }
|
_contact.value = { ...props.contact }
|
||||||
if (_contact.value.first_name) {
|
if (_contact.value.name) {
|
||||||
editMode.value = true
|
editMode.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -16,22 +16,63 @@
|
|||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<FormControl
|
||||||
<div class="mb-1.5 text-sm text-gray-600">Organization name</div>
|
type="text"
|
||||||
<TextInput
|
ref="title"
|
||||||
ref="title"
|
size="md"
|
||||||
variant="outline"
|
label="Organization name"
|
||||||
v-model="_organization.organization_name"
|
variant="outline"
|
||||||
placeholder="Add organization name"
|
v-model="_organization.organization_name"
|
||||||
/>
|
placeholder="Add organization name"
|
||||||
</div>
|
/>
|
||||||
<div>
|
<div class="flex gap-4">
|
||||||
<div class="mb-1.5 text-sm text-gray-600">Website</div>
|
<FormControl
|
||||||
<TextInput
|
class="flex-1"
|
||||||
|
type="text"
|
||||||
|
size="md"
|
||||||
|
label="Website"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
v-model="_organization.website"
|
v-model="_organization.website"
|
||||||
placeholder="Add 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -39,7 +80,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { ref, defineModel, nextTick, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@ -57,13 +99,19 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const organizations = defineModel('reloadOrganizations')
|
const organizations = defineModel('reloadOrganizations')
|
||||||
|
|
||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
let _organization = ref({})
|
let _organization = ref({
|
||||||
const router = useRouter()
|
organization_name: '',
|
||||||
|
website: '',
|
||||||
|
annual_revenue: '',
|
||||||
|
no_of_employees: '1-10',
|
||||||
|
industry: '',
|
||||||
|
})
|
||||||
|
|
||||||
async function updateOrganization(close) {
|
async function updateOrganization(close) {
|
||||||
const old = { ...props.organization }
|
const old = { ...props.organization }
|
||||||
@ -138,7 +186,8 @@ watch(
|
|||||||
if (!value) return
|
if (!value) return
|
||||||
editMode.value = false
|
editMode.value = false
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
title.value.el.focus()
|
// TODO: Issue with FormControl
|
||||||
|
// title.value.el.focus()
|
||||||
_organization.value = { ...props.organization }
|
_organization.value = { ...props.organization }
|
||||||
if (_organization.value.name) {
|
if (_organization.value.name) {
|
||||||
editMode.value = true
|
editMode.value = true
|
||||||
|
|||||||
@ -19,14 +19,14 @@
|
|||||||
type="email"
|
type="email"
|
||||||
v-model="newDeal[field.name]"
|
v-model="newDeal[field.name]"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<Link
|
||||||
v-else-if="field.type === 'link'"
|
v-else-if="field.type === 'link'"
|
||||||
type="autocomplete"
|
class="form-control"
|
||||||
:value="newDeal[field.name]"
|
:value="newDeal[field.name]"
|
||||||
:options="field.options"
|
:doctype="field.doctype"
|
||||||
@change="(e) => field.change(e)"
|
@change="(e) => field.change(e)"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
class="form-control"
|
:onCreate="field.create"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="field.type === 'user'"
|
v-else-if="field.type === 'user'"
|
||||||
@ -73,18 +73,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<OrganizationModal
|
||||||
|
v-model="showOrganizationModal"
|
||||||
|
:organization="_organization"
|
||||||
|
:options="{
|
||||||
|
redirect: false,
|
||||||
|
afterInsert: (doc) => (newLead.organization = doc.name),
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.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 { usersStore } from '@/stores/users'
|
||||||
import { organizationsStore } from '@/stores/organizations'
|
|
||||||
import { dealStatuses, statusDropdownOptions, activeAgents } from '@/utils'
|
import { dealStatuses, statusDropdownOptions, activeAgents } from '@/utils'
|
||||||
import { FormControl, Button, Dropdown, FeatherIcon } from 'frappe-ui'
|
import { FormControl, Button, Dropdown, FeatherIcon } from 'frappe-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
const { getOrganizationOptions } = organizationsStore()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
newDeal: {
|
newDeal: {
|
||||||
@ -93,6 +102,9 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showOrganizationModal = ref(false)
|
||||||
|
const _organization = ref({})
|
||||||
|
|
||||||
const allFields = [
|
const allFields = [
|
||||||
{
|
{
|
||||||
section: 'Deal Details',
|
section: 'Deal Details',
|
||||||
@ -146,9 +158,12 @@ const allFields = [
|
|||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
placeholder: 'Organization',
|
placeholder: 'Organization',
|
||||||
options: getOrganizationOptions(),
|
doctype: 'CRM Organization',
|
||||||
change: (option) => {
|
change: (data) => (newDeal.organization = data),
|
||||||
newDeal.organization = option.name
|
create: (value, close) => {
|
||||||
|
_organization.value.organization_name = value
|
||||||
|
showOrganizationModal.value = true
|
||||||
|
close()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -19,14 +19,14 @@
|
|||||||
type="email"
|
type="email"
|
||||||
v-model="newLead[field.name]"
|
v-model="newLead[field.name]"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<Link
|
||||||
v-else-if="field.type === 'link'"
|
v-else-if="field.type === 'link'"
|
||||||
type="autocomplete"
|
class="form-control"
|
||||||
:value="newLead[field.name]"
|
:value="newLead[field.name]"
|
||||||
:options="field.options"
|
:doctype="field.doctype"
|
||||||
@change="(e) => field.change(e)"
|
@change="(e) => field.change(e)"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
class="form-control"
|
:onCreate="field.create"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="field.type === 'user'"
|
v-else-if="field.type === 'user'"
|
||||||
@ -73,18 +73,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<OrganizationModal
|
||||||
|
v-model="showOrganizationModal"
|
||||||
|
:organization="_organization"
|
||||||
|
:options="{
|
||||||
|
redirect: false,
|
||||||
|
afterInsert: (doc) => (newLead.organization = doc.name),
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.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 { usersStore } from '@/stores/users'
|
||||||
import { organizationsStore } from '@/stores/organizations'
|
|
||||||
import { leadStatuses, statusDropdownOptions, activeAgents } from '@/utils'
|
import { leadStatuses, statusDropdownOptions, activeAgents } from '@/utils'
|
||||||
import { FormControl, Button, Dropdown, FeatherIcon } from 'frappe-ui'
|
import { FormControl, Button, Dropdown, FeatherIcon } from 'frappe-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
const { getOrganizationOptions } = organizationsStore()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
newLead: {
|
newLead: {
|
||||||
@ -93,6 +102,9 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showOrganizationModal = ref(false)
|
||||||
|
const _organization = ref({})
|
||||||
|
|
||||||
const allFields = [
|
const allFields = [
|
||||||
{
|
{
|
||||||
section: 'Lead Details',
|
section: 'Lead Details',
|
||||||
@ -146,9 +158,12 @@ const allFields = [
|
|||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
placeholder: 'Organization',
|
placeholder: 'Organization',
|
||||||
options: getOrganizationOptions(),
|
doctype: 'CRM Organization',
|
||||||
change: (option) => {
|
change: (data) => (props.newLead.organization = data),
|
||||||
props.newLead.organization = option.value
|
create: (value, close) => {
|
||||||
|
_organization.value.organization_name = value
|
||||||
|
showOrganizationModal.value = true
|
||||||
|
close()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<button
|
<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="flex w-full items-center justify-between focus:outline-none"
|
||||||
:class="{ 'bg-gray-200': isComboboxOpen }"
|
:class="inputClasses"
|
||||||
@click="() => togglePopover()"
|
@click="() => togglePopover()"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@ -32,8 +32,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen">
|
||||||
<div class="mt-1 rounded-lg bg-white text-base shadow-2xl">
|
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||||
<div class="relative p-1.5 pb-0">
|
<div class="relative px-1.5 pt-0.5">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="search"
|
ref="search"
|
||||||
class="form-input w-full"
|
class="form-input w-full"
|
||||||
@ -98,7 +98,7 @@
|
|||||||
No results found
|
No results found
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOptions>
|
</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
|
<slot
|
||||||
name="footer"
|
name="footer"
|
||||||
v-bind="{ value: search?.el._value, close }"
|
v-bind="{ value: search?.el._value, close }"
|
||||||
@ -121,7 +121,36 @@ import {
|
|||||||
import { Popover, Button, FeatherIcon } from 'frappe-ui'
|
import { Popover, Button, FeatherIcon } from 'frappe-ui'
|
||||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
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 emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
@ -163,7 +192,7 @@ const groups = computed(() => {
|
|||||||
key: i,
|
key: i,
|
||||||
group: group.group,
|
group: group.group,
|
||||||
hideLabel: group.hideLabel || false,
|
hideLabel: group.hideLabel || false,
|
||||||
items: filterOptions(group.items),
|
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((group) => group.items.length > 0)
|
.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>
|
</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 }}
|
{{ field.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<FormControl
|
<Dropdown
|
||||||
v-if="field.type === 'email'"
|
v-if="field.type === 'dropdown' && field.options.length"
|
||||||
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]"
|
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
@change="(e) => field.change(e)"
|
class="form-control show-dropdown-icon w-full flex-1"
|
||||||
:placeholder="field.placeholder"
|
>
|
||||||
|
<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"
|
class="form-control"
|
||||||
|
:value="contact[field.name]"
|
||||||
|
:doctype="field.doctype"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
@change="(e) => field.change(e)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else
|
v-else
|
||||||
@ -155,12 +180,14 @@
|
|||||||
v-if="tab.label === 'Leads' && rows.length"
|
v-if="tab.label === 'Leads' && rows.length"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
:options="{ selectable: false }"
|
||||||
/>
|
/>
|
||||||
<DealsListView
|
<DealsListView
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
v-if="tab.label === 'Deals' && rows.length"
|
v-else-if="tab.label === 'Deals' && rows.length"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
:options="{ selectable: false }"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!rows.length"
|
v-if="!rows.length"
|
||||||
@ -174,28 +201,41 @@
|
|||||||
</template>
|
</template>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
Dialog,
|
||||||
Avatar,
|
Avatar,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
Dropdown,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Tabs,
|
Tabs,
|
||||||
call,
|
call,
|
||||||
createResource,
|
createResource,
|
||||||
createListResource,
|
createListResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
|
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
||||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||||
|
import DropdownItem from '@/components/DropdownItem.vue'
|
||||||
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
|
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
|
||||||
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
|
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
|
||||||
import DealsListView from '@/components/ListViews/DealsListView.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 { contactsStore } from '@/stores/contacts.js'
|
||||||
import { organizationsStore } from '@/stores/organizations.js'
|
import { organizationsStore } from '@/stores/organizations.js'
|
||||||
import { ref, computed, h } from 'vue'
|
import { ref, computed, h } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { getContactByName, contacts } = contactsStore()
|
const { getContactByName, contacts } = contactsStore()
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
const { getOrganization, getOrganizationOptions } = organizationsStore()
|
const { getOrganization } = organizationsStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contactId: {
|
contactId: {
|
||||||
@ -224,6 +265,8 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const contact = computed(() => getContactByName(props.contactId))
|
const contact = computed(() => getContactByName(props.contactId))
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
@ -268,6 +311,7 @@ async function deleteContact() {
|
|||||||
})
|
})
|
||||||
contacts.reload()
|
contacts.reload()
|
||||||
close()
|
close()
|
||||||
|
router.push({ name: 'Contacts' })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -305,7 +349,7 @@ const leads = createListResource({
|
|||||||
'modified',
|
'modified',
|
||||||
],
|
],
|
||||||
filters: {
|
filters: {
|
||||||
email: contact.value.email_id,
|
email: contact.value?.email_id,
|
||||||
converted: 0,
|
converted: 0,
|
||||||
},
|
},
|
||||||
orderBy: 'modified desc',
|
orderBy: 'modified desc',
|
||||||
@ -313,26 +357,12 @@ const leads = createListResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const deals = createListResource({
|
const deals = createResource({
|
||||||
type: 'list',
|
url: 'crm.api.contact.get_linked_deals',
|
||||||
doctype: 'CRM Lead',
|
|
||||||
cache: ['deals', props.contactId],
|
cache: ['deals', props.contactId],
|
||||||
fields: [
|
params: {
|
||||||
'name',
|
contact: props.contactId,
|
||||||
'organization',
|
|
||||||
'annual_revenue',
|
|
||||||
'status',
|
|
||||||
'email',
|
|
||||||
'mobile_no',
|
|
||||||
'lead_owner',
|
|
||||||
'modified',
|
|
||||||
],
|
|
||||||
filters: {
|
|
||||||
email: contact.value.email_id,
|
|
||||||
converted: 1,
|
|
||||||
},
|
},
|
||||||
orderBy: 'modified desc',
|
|
||||||
pageLength: 20,
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -394,9 +424,9 @@ function getDealRowObject(deal) {
|
|||||||
},
|
},
|
||||||
email: deal.email,
|
email: deal.email,
|
||||||
mobile_no: deal.mobile_no,
|
mobile_no: deal.mobile_no,
|
||||||
lead_owner: {
|
deal_owner: {
|
||||||
label: deal.lead_owner && getUser(deal.lead_owner).full_name,
|
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
|
||||||
...(deal.lead_owner && getUser(deal.lead_owner)),
|
...(deal.deal_owner && getUser(deal.deal_owner)),
|
||||||
},
|
},
|
||||||
modified: {
|
modified: {
|
||||||
label: dateFormat(deal.modified, dateTooltipFormat),
|
label: dateFormat(deal.modified, dateTooltipFormat),
|
||||||
@ -470,8 +500,8 @@ const dealColumns = [
|
|||||||
width: '11rem',
|
width: '11rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Lead owner',
|
label: 'Deal owner',
|
||||||
key: 'lead_owner',
|
key: 'deal_owner',
|
||||||
width: '10rem',
|
width: '10rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -487,21 +517,11 @@ const details = computed(() => {
|
|||||||
label: 'Salutation',
|
label: 'Salutation',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'salutation',
|
name: 'salutation',
|
||||||
placeholder: 'Mr./Mrs./Ms.',
|
placeholder: 'Mr./Mrs./Ms...',
|
||||||
options: [
|
doctype: 'Salutation',
|
||||||
{ label: 'Dr', value: 'Dr' },
|
change: (value) => {
|
||||||
{ label: 'Mr', value: 'Mr' },
|
contact.value.salutation = value
|
||||||
{ label: 'Mrs', value: 'Mrs' },
|
updateContact('salutation', value)
|
||||||
{ 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)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -516,35 +536,98 @@ const details = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
type: 'email',
|
type: 'dropdown',
|
||||||
name: 'email',
|
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.',
|
label: 'Mobile no.',
|
||||||
type: 'phone',
|
type: 'dropdown',
|
||||||
name: 'mobile_no',
|
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',
|
label: 'Organization',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'company_name',
|
name: 'company_name',
|
||||||
placeholder: 'Select organization',
|
placeholder: 'Select organization',
|
||||||
options: getOrganizationOptions(),
|
doctype: 'CRM Organization',
|
||||||
change: (data) => {
|
change: (value) => {
|
||||||
contact.value.company_name = data.value
|
contact.value.company_name = value
|
||||||
updateContact('company_name', data.value)
|
updateContact('company_name', value)
|
||||||
},
|
},
|
||||||
link: (data) => {
|
link: (data) => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Organization',
|
name: 'Organization',
|
||||||
params: { organizationId: data.value },
|
params: { organizationId: data },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const new_field = ref({})
|
||||||
|
|
||||||
|
const dialogOptions = ref({})
|
||||||
|
|
||||||
function updateContact(fieldname, value) {
|
function updateContact(fieldname, value) {
|
||||||
|
if (['mobile_no', 'email_id'].includes(fieldname)) {
|
||||||
|
details.value.find((d) => d.name === fieldname).create(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
createResource({
|
createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
params: {
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -596,4 +712,14 @@ function updateContact(fieldname, value) {
|
|||||||
color: white;
|
color: white;
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(:has(> .dropdown-button)) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-button > button > span) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
type="autocomplete"
|
type="autocomplete"
|
||||||
:options="activeAgents"
|
:options="activeAgents"
|
||||||
:value="getUser(deal.data.deal_owner).full_name"
|
:value="getUser(deal.data.deal_owner).full_name"
|
||||||
@change="(option) => updateAssignedAgent(option.email)"
|
@change="(option) => updateField('deal_owner', option.email)"
|
||||||
placeholder="Deal owner"
|
placeholder="Deal owner"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@ -18,7 +18,9 @@
|
|||||||
<UserAvatar class="mr-2" :user="option.email" size="sm" />
|
<UserAvatar class="mr-2" :user="option.email" size="sm" />
|
||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Dropdown :options="statusDropdownOptions(deal.data, 'deal', updateDeal)">
|
<Dropdown
|
||||||
|
:options="statusDropdownOptions(deal.data, 'deal', updateField)"
|
||||||
|
>
|
||||||
<template #default="{ open }">
|
<template #default="{ open }">
|
||||||
<Button :label="deal.data.status">
|
<Button :label="deal.data.status">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@ -100,16 +102,47 @@
|
|||||||
:class="{ 'border-b': i !== detailSections.length - 1 }"
|
:class="{ 'border-b': i !== detailSections.length - 1 }"
|
||||||
>
|
>
|
||||||
<Toggler :is-opened="section.opened" v-slot="{ opened, toggle }">
|
<Toggler :is-opened="section.opened" v-slot="{ opened, toggle }">
|
||||||
<div
|
<div class="flex items-center justify-between">
|
||||||
class="flex max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
|
<div
|
||||||
@click="toggle()"
|
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"
|
<FeatherIcon
|
||||||
class="h-4 text-gray-600 transition-all duration-300 ease-in-out"
|
name="chevron-right"
|
||||||
:class="{ 'rotate-90': opened }"
|
class="h-4 text-gray-900 transition-all duration-300 ease-in-out"
|
||||||
/>
|
:class="{ 'rotate-90': opened }"
|
||||||
{{ section.label }}
|
/>
|
||||||
|
{{ 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>
|
</div>
|
||||||
<transition
|
<transition
|
||||||
enter-active-class="duration-300 ease-in"
|
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="opened" class="flex flex-col gap-1.5">
|
||||||
<div
|
<div
|
||||||
|
v-if="section.fields"
|
||||||
v-for="field in section.fields"
|
v-for="field in section.fields"
|
||||||
:key="field.label"
|
:key="field.label"
|
||||||
class="flex items-center gap-2 px-3 text-base leading-5 first:mt-3"
|
class="flex items-center gap-2 px-3 text-base leading-5 first:mt-3"
|
||||||
@ -129,105 +163,15 @@
|
|||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<FormControl
|
<Link
|
||||||
v-if="field.type === 'select'"
|
v-if="field.type === 'link'"
|
||||||
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"
|
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:value="deal.data[field.name]"
|
:value="deal.data[field.name]"
|
||||||
@change.stop="
|
:doctype="field.doctype"
|
||||||
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)"
|
|
||||||
:placeholder="field.placeholder"
|
: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
|
<FormControl
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
type="date"
|
type="date"
|
||||||
@ -238,26 +182,6 @@
|
|||||||
:debounce="500"
|
:debounce="500"
|
||||||
class="form-control"
|
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
|
<Tooltip
|
||||||
:text="field.tooltip"
|
:text="field.tooltip"
|
||||||
class="flex h-7 cursor-pointer items-center px-2 py-1"
|
class="flex h-7 cursor-pointer items-center px-2 py-1"
|
||||||
@ -286,6 +210,110 @@
|
|||||||
@click="field.link(deal.data[field.name])"
|
@click="field.link(deal.data[field.name])"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</Toggler>
|
</Toggler>
|
||||||
@ -294,6 +322,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
|
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 IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
||||||
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
|
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
|
||||||
|
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import Toggler from '@/components/Toggler.vue'
|
import Toggler from '@/components/Toggler.vue'
|
||||||
import Activities from '@/components/Activities.vue'
|
import Activities from '@/components/Activities.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.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 {
|
import {
|
||||||
dealStatuses,
|
dealStatuses,
|
||||||
statusDropdownOptions,
|
statusDropdownOptions,
|
||||||
@ -327,13 +378,15 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Tabs,
|
Tabs,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
call,
|
||||||
|
Badge,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, h } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
const { contacts } = contactsStore()
|
const { getContactByName, contacts } = contactsStore()
|
||||||
const { getOrganization, getOrganizationOptions } = organizationsStore()
|
const { organizations, getOrganization } = organizationsStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -351,8 +404,12 @@ const deal = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const reload = ref(false)
|
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({
|
createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
params: {
|
params: {
|
||||||
@ -371,6 +428,7 @@ function updateDeal(fieldname, value) {
|
|||||||
icon: 'check',
|
icon: 'check',
|
||||||
iconClasses: 'text-green-600',
|
iconClasses: 'text-green-600',
|
||||||
})
|
})
|
||||||
|
callback?.()
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
createToast({
|
createToast({
|
||||||
@ -427,10 +485,12 @@ const detailSections = computed(() => {
|
|||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
placeholder: 'Select organization',
|
placeholder: 'Select organization',
|
||||||
options: getOrganizationOptions(),
|
doctype: 'CRM Organization',
|
||||||
change: (data) => {
|
change: (data) => updateField('organization', data),
|
||||||
deal.data.organization = data.value
|
create: (value, close) => {
|
||||||
updateDeal('organization', data.value)
|
_organization.value.organization_name = value
|
||||||
|
showOrganizationModal.value = true
|
||||||
|
close()
|
||||||
},
|
},
|
||||||
link: () => {
|
link: () => {
|
||||||
router.push({
|
router.push({
|
||||||
@ -475,66 +535,102 @@ const detailSections = computed(() => {
|
|||||||
{
|
{
|
||||||
label: 'Contacts',
|
label: 'Contacts',
|
||||||
opened: true,
|
opened: true,
|
||||||
fields: [
|
contacts: deal.data.contacts.map((contact) => {
|
||||||
{
|
return {
|
||||||
label: 'Salutation',
|
name: contact.contact,
|
||||||
type: 'link',
|
is_primary: contact.is_primary,
|
||||||
name: 'salutation',
|
opened: false,
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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(() => {
|
const organization = computed(() => {
|
||||||
return getOrganization(deal.data.organization)
|
return getOrganization(deal.data.organization)
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateAssignedAgent(email) {
|
function updateField(name, value, callback) {
|
||||||
deal.data.deal_owner = email
|
updateDeal(name, value, () => {
|
||||||
updateDeal('deal_owner', email)
|
deal.data[name] = value
|
||||||
|
callback?.()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
:deep(.form-control input),
|
:deep(.form-control input),
|
||||||
:deep(.form-control select),
|
|
||||||
:deep(.form-control button) {
|
:deep(.form-control button) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: white;
|
background: white;
|
||||||
|
|||||||
@ -18,7 +18,9 @@
|
|||||||
<UserAvatar class="mr-2" :user="option.email" size="sm" />
|
<UserAvatar class="mr-2" :user="option.email" size="sm" />
|
||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Dropdown :options="statusDropdownOptions(lead.data, 'lead', updateLead)">
|
<Dropdown
|
||||||
|
:options="statusDropdownOptions(lead.data, 'lead', updateField)"
|
||||||
|
>
|
||||||
<template #default="{ open }">
|
<template #default="{ open }">
|
||||||
<Button :label="lead.data.status">
|
<Button :label="lead.data.status">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@ -32,11 +34,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Button
|
<Button label="Convert to deal" variant="solid" @click="convertToDeal" />
|
||||||
label="Convert to deal"
|
|
||||||
variant="solid"
|
|
||||||
@click="convertToDeal()"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</LayoutHeader>
|
</LayoutHeader>
|
||||||
<div v-if="lead?.data" class="flex h-full overflow-hidden">
|
<div v-if="lead?.data" class="flex h-full overflow-hidden">
|
||||||
@ -54,7 +52,10 @@
|
|||||||
>
|
>
|
||||||
About this lead
|
About this lead
|
||||||
</div>
|
</div>
|
||||||
<FileUploader @success="changeLeadImage" :validateFile="validateFile">
|
<FileUploader
|
||||||
|
@success="(file) => updateField('image', file.file_url)"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
>
|
||||||
<template #default="{ openFileSelector, error }">
|
<template #default="{ openFileSelector, error }">
|
||||||
<div class="flex items-center justify-start gap-5 p-5">
|
<div class="flex items-center justify-start gap-5 p-5">
|
||||||
<div class="group relative h-[88px] w-[88px]">
|
<div class="group relative h-[88px] w-[88px]">
|
||||||
@ -80,7 +81,7 @@
|
|||||||
{
|
{
|
||||||
icon: 'trash-2',
|
icon: 'trash-2',
|
||||||
label: 'Remove image',
|
label: 'Remove image',
|
||||||
onClick: () => changeLeadImage(''),
|
onClick: () => updateField('image', ''),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@ -197,29 +198,15 @@
|
|||||||
"
|
"
|
||||||
:debounce="500"
|
:debounce="500"
|
||||||
/>
|
/>
|
||||||
<Autocomplete
|
<Link
|
||||||
v-else-if="field.type === '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"
|
class="form-control"
|
||||||
>
|
:value="lead.data[field.name]"
|
||||||
<template #footer="{ value, close }">
|
:doctype="field.doctype"
|
||||||
<div>
|
:placeholder="field.placeholder"
|
||||||
<Button
|
@change="(e) => field.change(e)"
|
||||||
variant="ghost"
|
:onCreate="field.create"
|
||||||
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>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="field.type === 'user'"
|
v-else-if="field.type === 'user'"
|
||||||
type="autocomplete"
|
type="autocomplete"
|
||||||
@ -254,37 +241,6 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</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
|
<Tooltip
|
||||||
:text="field.tooltip"
|
:text="field.tooltip"
|
||||||
class="flex h-7 cursor-pointer items-center px-2 py-1"
|
class="flex h-7 cursor-pointer items-center px-2 py-1"
|
||||||
@ -326,7 +282,10 @@
|
|||||||
:organization="_organization"
|
:organization="_organization"
|
||||||
:options="{
|
:options="{
|
||||||
redirect: false,
|
redirect: false,
|
||||||
afterInsert: (doc) => updateField('organiation', doc.name),
|
afterInsert: (doc) =>
|
||||||
|
updateField('organization', doc.name, () => {
|
||||||
|
organizations.reload()
|
||||||
|
}),
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -345,7 +304,7 @@ import Toggler from '@/components/Toggler.vue'
|
|||||||
import Activities from '@/components/Activities.vue'
|
import Activities from '@/components/Activities.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import {
|
import {
|
||||||
leadStatuses,
|
leadStatuses,
|
||||||
statusDropdownOptions,
|
statusDropdownOptions,
|
||||||
@ -374,7 +333,7 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
const { contacts } = contactsStore()
|
const { contacts } = contactsStore()
|
||||||
const { getOrganization, getOrganizationOptions } = organizationsStore()
|
const { organizations, getOrganization } = organizationsStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -395,7 +354,9 @@ const reload = ref(false)
|
|||||||
const showOrganizationModal = ref(false)
|
const showOrganizationModal = ref(false)
|
||||||
const _organization = ref({})
|
const _organization = ref({})
|
||||||
|
|
||||||
function updateLead(fieldname, value) {
|
function updateLead(fieldname, value, callback) {
|
||||||
|
value = Array.isArray(fieldname) ? '' : value
|
||||||
|
|
||||||
createResource({
|
createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
params: {
|
params: {
|
||||||
@ -407,13 +368,13 @@ function updateLead(fieldname, value) {
|
|||||||
auto: true,
|
auto: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
lead.reload()
|
lead.reload()
|
||||||
contacts.reload()
|
|
||||||
reload.value = true
|
reload.value = true
|
||||||
createToast({
|
createToast({
|
||||||
title: 'Lead updated',
|
title: 'Lead updated',
|
||||||
icon: 'check',
|
icon: 'check',
|
||||||
iconClasses: 'text-green-600',
|
iconClasses: 'text-green-600',
|
||||||
})
|
})
|
||||||
|
callback?.()
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
createToast({
|
createToast({
|
||||||
@ -459,11 +420,6 @@ const tabs = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function changeLeadImage(file) {
|
|
||||||
lead.data.image = file.file_url
|
|
||||||
updateLead('image', file.file_url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateFile(file) {
|
function validateFile(file) {
|
||||||
let extn = file.name.split('.').pop().toLowerCase()
|
let extn = file.name.split('.').pop().toLowerCase()
|
||||||
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
|
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
|
||||||
@ -482,19 +438,18 @@ const detailSections = computed(() => {
|
|||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
placeholder: 'Select organization',
|
placeholder: 'Select organization',
|
||||||
options: getOrganizationOptions(),
|
doctype: 'CRM Organization',
|
||||||
change: (data) => data && updateField('organization', data.value),
|
change: (data) => data && updateField('organization', data),
|
||||||
create: (value, close) => {
|
create: (value, close) => {
|
||||||
_organization.value.organization_name = value
|
_organization.value.organization_name = value
|
||||||
showOrganizationModal.value = true
|
showOrganizationModal.value = true
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
link: () => {
|
link: () =>
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Organization',
|
name: 'Organization',
|
||||||
params: { organizationId: organization.value?.name },
|
params: { organizationId: lead.data.organization },
|
||||||
})
|
}),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Website',
|
label: 'Website',
|
||||||
@ -504,6 +459,14 @@ const detailSections = computed(() => {
|
|||||||
tooltip:
|
tooltip:
|
||||||
'It is a read only field, value is fetched from organization',
|
'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',
|
label: 'Job title',
|
||||||
type: 'data',
|
type: 'data',
|
||||||
@ -514,31 +477,8 @@ const detailSections = computed(() => {
|
|||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'source',
|
name: 'source',
|
||||||
placeholder: 'Select source...',
|
placeholder: 'Select source...',
|
||||||
options: [
|
doctype: 'CRM Lead Source',
|
||||||
{ label: 'Advertisement', value: 'Advertisement' },
|
change: (data) => updateField('source', data),
|
||||||
{ 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)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -550,22 +490,9 @@ const detailSections = computed(() => {
|
|||||||
label: 'Salutation',
|
label: 'Salutation',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'salutation',
|
name: 'salutation',
|
||||||
placeholder: 'Mr./Mrs./Ms.',
|
placeholder: 'Mr./Mrs./Ms...',
|
||||||
options: [
|
doctype: 'Salutation',
|
||||||
{ label: 'Dr', value: 'Dr' },
|
change: (data) => updateField('salutation', data),
|
||||||
{ 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)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'First name',
|
label: 'First name',
|
||||||
@ -596,30 +523,21 @@ const organization = computed(() => {
|
|||||||
return getOrganization(lead.data.organization)
|
return getOrganization(lead.data.organization)
|
||||||
})
|
})
|
||||||
|
|
||||||
function convertToDeal() {
|
async function convertToDeal() {
|
||||||
lead.data.status = 'Qualified'
|
let deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
||||||
lead.data.converted = 1
|
lead: lead.data.name,
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if (d.name) {
|
if (deal) {
|
||||||
router.push({ name: 'Deal', params: { dealId: d.name } })
|
await contacts.reload()
|
||||||
|
router.push({ name: 'Deal', params: { dealId: deal } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateField(name, value) {
|
function updateField(name, value, callback) {
|
||||||
lead.data[name] = value
|
updateLead(name, value, () => {
|
||||||
updateLead(name, value)
|
lead.data[name] = value
|
||||||
|
callback?.()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -61,25 +61,30 @@
|
|||||||
<span class="">{{ website(organization.website) }}</span>
|
<span class="">{{ website(organization.website) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="organization.email_id"
|
v-if="organization.industry && organization.website"
|
||||||
class="text-3xl leading-[0] text-gray-600"
|
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>
|
||||||
<span class="">{{ organization.email_id }}</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>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="
|
v-if="
|
||||||
(organization.name || organization.email_id) &&
|
(organization.website || organization.industry) &&
|
||||||
organization.mobile_no
|
organization.annual_revenue
|
||||||
"
|
"
|
||||||
class="text-3xl leading-[0] text-gray-600"
|
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>
|
||||||
<span class="">{{ organization.mobile_no }}</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>
|
</div>
|
||||||
<div class="mt-1 flex gap-2">
|
<div class="mt-1 flex gap-2">
|
||||||
@ -137,18 +142,21 @@
|
|||||||
v-if="tab.label === 'Leads' && rows.length"
|
v-if="tab.label === 'Leads' && rows.length"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
:options="{ selectable: false }"
|
||||||
/>
|
/>
|
||||||
<DealsListView
|
<DealsListView
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
v-if="tab.label === 'Deals' && rows.length"
|
v-if="tab.label === 'Deals' && rows.length"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
:options="{ selectable: false }"
|
||||||
/>
|
/>
|
||||||
<ContactsListView
|
<ContactsListView
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
v-if="tab.label === 'Contacts' && rows.length"
|
v-if="tab.label === 'Contacts' && rows.length"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
:options="{ selectable: false }"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!rows.length"
|
v-if="!rows.length"
|
||||||
@ -307,7 +315,7 @@ const leads = createListResource({
|
|||||||
|
|
||||||
const deals = createListResource({
|
const deals = createListResource({
|
||||||
type: 'list',
|
type: 'list',
|
||||||
doctype: 'CRM Lead',
|
doctype: 'CRM Deal',
|
||||||
cache: ['deals', props.organization.name],
|
cache: ['deals', props.organization.name],
|
||||||
fields: [
|
fields: [
|
||||||
'name',
|
'name',
|
||||||
@ -316,12 +324,11 @@ const deals = createListResource({
|
|||||||
'status',
|
'status',
|
||||||
'email',
|
'email',
|
||||||
'mobile_no',
|
'mobile_no',
|
||||||
'lead_owner',
|
'deal_owner',
|
||||||
'modified',
|
'modified',
|
||||||
],
|
],
|
||||||
filters: {
|
filters: {
|
||||||
organization: props.organization.name,
|
organization: props.organization.name,
|
||||||
converted: 1,
|
|
||||||
},
|
},
|
||||||
orderBy: 'modified desc',
|
orderBy: 'modified desc',
|
||||||
pageLength: 20,
|
pageLength: 20,
|
||||||
@ -415,9 +422,9 @@ function getDealRowObject(deal) {
|
|||||||
},
|
},
|
||||||
email: deal.email,
|
email: deal.email,
|
||||||
mobile_no: deal.mobile_no,
|
mobile_no: deal.mobile_no,
|
||||||
lead_owner: {
|
deal_owner: {
|
||||||
label: deal.lead_owner && getUser(deal.lead_owner).full_name,
|
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
|
||||||
...(deal.lead_owner && getUser(deal.lead_owner)),
|
...(deal.deal_owner && getUser(deal.deal_owner)),
|
||||||
},
|
},
|
||||||
modified: {
|
modified: {
|
||||||
label: dateFormat(deal.modified, dateTooltipFormat),
|
label: dateFormat(deal.modified, dateTooltipFormat),
|
||||||
@ -512,8 +519,8 @@ const dealColumns = [
|
|||||||
width: '11rem',
|
width: '11rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Lead owner',
|
label: 'Deal owner',
|
||||||
key: 'lead_owner',
|
key: 'deal_owner',
|
||||||
width: '10rem',
|
width: '10rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -78,7 +78,6 @@ export function statusDropdownOptions(data, doctype, action) {
|
|||||||
label: statuses[status].label,
|
label: statuses[status].label,
|
||||||
icon: () => h(IndicatorIcon, { class: statuses[status].color }),
|
icon: () => h(IndicatorIcon, { class: statuses[status].color }),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
data.status = statuses[status].label
|
|
||||||
action && action('status', statuses[status].label)
|
action && action('status', statuses[status].label)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user