diff --git a/crm/api/contact.py b/crm/api/contact.py new file mode 100644 index 000000000..e13fc4694 --- /dev/null +++ b/crm/api/contact.py @@ -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 diff --git a/crm/api/session.py b/crm/api/session.py index 758c53b6c..2b7e2d074 100644 --- a/crm/api/session.py +++ b/crm/api/session.py @@ -23,12 +23,37 @@ def get_contacts(): if frappe.session.user == "Guest": frappe.throw("Authentication failed", exc=frappe.AuthenticationError) - contacts = frappe.qb.get_query( + contacts = frappe.get_all( "Contact", - fields=['name', 'first_name', 'last_name', 'full_name', 'image', 'email_id', 'mobile_no', 'phone', 'salutation', 'company_name', 'modified'], + fields=[ + "name", + "salutation", + "first_name", + "last_name", + "full_name", + "image", + "email_id", + "mobile_no", + "phone", + "company_name", + "modified" + ], order_by="first_name asc", distinct=True, - ).run(as_dict=1) + ) + + for contact in contacts: + contact["email_ids"] = frappe.get_all( + "Contact Email", + filters={"parenttype": "Contact", "parent": contact.name}, + fields=["email_id", "is_primary"], + ) + + contact["phone_nos"] = frappe.get_all( + "Contact Phone", + filters={"parenttype": "Contact", "parent": contact.name}, + fields=["phone", "is_primary_phone", "is_primary_mobile_no"], + ) return contacts @@ -39,7 +64,7 @@ def get_organizations(): organizations = frappe.qb.get_query( "CRM Organization", - fields=['name', 'organization_name', 'organization_logo', 'website'], + fields=['*'], order_by="name asc", distinct=True, ).run(as_dict=1) diff --git a/crm/fcrm/doctype/crm_contacts/crm_contacts.json b/crm/fcrm/doctype/crm_contacts/crm_contacts.json index e6044d67b..5bca79f91 100644 --- a/crm/fcrm/doctype/crm_contacts/crm_contacts.json +++ b/crm/fcrm/doctype/crm_contacts/crm_contacts.json @@ -12,7 +12,8 @@ "column_break_uvny", "gender", "mobile_no", - "phone" + "phone", + "is_primary" ], "fields": [ { @@ -55,7 +56,6 @@ "fetch_from": "contact.phone", "fieldname": "phone", "fieldtype": "Data", - "in_list_view": 1, "label": "Phone", "options": "Phone", "read_only": 1 @@ -64,16 +64,22 @@ "fetch_from": "contact.gender", "fieldname": "gender", "fieldtype": "Link", - "in_list_view": 1, "label": "Gender", "options": "Gender", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_primary", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Primary" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-08-25 19:19:27.813526", + "modified": "2023-11-12 14:58:18.846919", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Contacts", diff --git a/crm/fcrm/doctype/crm_deal/api.py b/crm/fcrm/doctype/crm_deal/api.py index 0dc14355c..a995e65a3 100644 --- a/crm/fcrm/doctype/crm_deal/api.py +++ b/crm/fcrm/doctype/crm_deal/api.py @@ -1,9 +1,5 @@ -import json - import frappe from frappe import _ -from frappe.desk.form.load import get_docinfo -from crm.fcrm.doctype.crm_lead.api import get_activities as get_lead_activities @frappe.whitelist() @@ -22,4 +18,11 @@ def get_deal(name): frappe.throw(_("Deal not found"), frappe.DoesNotExistError) deal = deal.pop() + + deal["contacts"] = frappe.get_all( + "CRM Contacts", + filters={"parenttype": "CRM Deal", "parent": deal.name}, + fields=["contact", "is_primary"], + ) + return deal diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.json b/crm/fcrm/doctype/crm_deal/crm_deal.json index 41289ef09..1363f8099 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.json +++ b/crm/fcrm/doctype/crm_deal/crm_deal.json @@ -21,7 +21,8 @@ "column_break_bqvs", "contacts_tab", "email", - "mobile_no" + "mobile_no", + "contacts" ], "fields": [ { @@ -114,11 +115,17 @@ "options": "Qualification\nDemo/Making\nProposal/Quotation\nNegotiation\nReady to Close\nWon\nLost", "reqd": 1, "search_index": 1 + }, + { + "fieldname": "contacts", + "fieldtype": "Table", + "label": "Contacts", + "options": "CRM Contacts" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-11-06 21:53:50.442404", + "modified": "2023-11-09 19:58:15.620483", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Deal", diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py index 3cfcf3479..c1f34b28d 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.py +++ b/crm/fcrm/doctype/crm_deal/crm_deal.py @@ -1,11 +1,50 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe +from frappe import _ from frappe.model.document import Document class CRMDeal(Document): + def validate(self): + self.set_primary_contact() + self.set_primary_email_mobile_no() + + def set_primary_contact(self, contact=None): + if not self.contacts: + return + + if not contact and len(self.contacts) == 1: + self.contacts[0].is_primary = 1 + elif contact: + for d in self.contacts: + if d.contact == contact: + d.is_primary = 1 + else: + d.is_primary = 0 + + def set_primary_email_mobile_no(self): + if not self.contacts: + self.email = "" + self.mobile_no = "" + return + + if len([contact for contact in self.contacts if contact.is_primary]) > 1: + frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold("Contact"))) + + primary_contact_exists = False + for d in self.contacts: + if d.is_primary == 1: + primary_contact_exists = True + self.email = d.email.strip() + self.mobile_no = d.mobile_no.strip() + break + + if not primary_contact_exists: + self.email = "" + self.mobile_no = "" + @staticmethod def sort_options(): return [ @@ -17,3 +56,33 @@ def sort_options(): { "label": 'Email', "value": 'email' }, { "label": 'Mobile no', "value": 'mobile_no' }, ] + +@frappe.whitelist() +def add_contact(deal, contact): + if not frappe.has_permission("CRM Deal", "write", deal): + frappe.throw(_("Not allowed to add contact to Deal"), frappe.PermissionError) + + deal = frappe.get_cached_doc("CRM Deal", deal) + deal.append("contacts", {"contact": contact}) + deal.save() + return True + +@frappe.whitelist() +def remove_contact(deal, contact): + if not frappe.has_permission("CRM Deal", "write", deal): + frappe.throw(_("Not allowed to remove contact from Deal"), frappe.PermissionError) + + deal = frappe.get_cached_doc("CRM Deal", deal) + deal.contacts = [d for d in deal.contacts if d.contact != contact] + deal.save() + return True + +@frappe.whitelist() +def set_primary_contact(deal, contact): + if not frappe.has_permission("CRM Deal", "write", deal): + frappe.throw(_("Not allowed to set primary contact for Deal"), frappe.PermissionError) + + deal = frappe.get_cached_doc("CRM Deal", deal) + deal.set_primary_contact(contact) + deal.save() + return True \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.json b/crm/fcrm/doctype/crm_lead/crm_lead.json index 8257a2e5f..3aaa2ad60 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.json +++ b/crm/fcrm/doctype/crm_lead/crm_lead.json @@ -20,6 +20,7 @@ "column_break_lcuv", "lead_owner", "status", + "job_title", "source", "converted", "organization_tab", @@ -28,7 +29,6 @@ "no_of_employees", "column_break_dbsv", "website", - "job_title", "annual_revenue", "industry", "contact_tab", @@ -37,9 +37,7 @@ "column_break_sijm", "mobile_no", "column_break_sjtw", - "phone", - "section_break_jyxr", - "contacts" + "phone" ], "fields": [ { @@ -187,11 +185,9 @@ "search_index": 1 }, { - "fetch_from": "organization.job_title", "fieldname": "job_title", "fieldtype": "Data", - "label": "Job Title", - "read_only": 1 + "label": "Job Title" }, { "fieldname": "organization_tab", @@ -203,16 +199,6 @@ "fieldtype": "Tab Break", "label": "Contact" }, - { - "fieldname": "contacts", - "fieldtype": "Table", - "label": "Contacts", - "options": "CRM Contacts" - }, - { - "fieldname": "section_break_jyxr", - "fieldtype": "Section Break" - }, { "fieldname": "organization", "fieldtype": "Link", @@ -231,7 +217,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2023-11-06 21:53:32.542503", + "modified": "2023-11-13 13:35:35.783003", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Lead", diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.py b/crm/fcrm/doctype/crm_lead/crm_lead.py index 56436a7c6..362b4a10e 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.py +++ b/crm/fcrm/doctype/crm_lead/crm_lead.py @@ -14,9 +14,7 @@ def validate(self): self.set_lead_name() self.set_title() self.validate_email() - if not self.is_new(): - self.validate_contact() - + def set_full_name(self): if self.first_name: self.lead_name = " ".join( @@ -37,7 +35,7 @@ def set_lead_name(self): def set_title(self): self.title = self.organization or self.lead_name - + def validate_email(self): if self.email: if not self.flags.ignore_email_validation: @@ -48,58 +46,15 @@ def validate_email(self): if self.is_new() or not self.image: self.image = has_gravatar(self.email) - - def validate_contact(self): - link = frappe.db.exists("Dynamic Link", {"link_doctype": "CRM Lead", "link_name": self.name}) - - if link: - for field in ["first_name", "last_name", "email", "mobile_no", "phone", "salutation", "image"]: - if self.has_value_changed(field): - contact = frappe.db.get_value("Dynamic Link", link, "parent") - contact_doc = frappe.get_doc("Contact", contact) - contact_doc.update({ - "first_name": self.first_name or self.lead_name, - "last_name": self.last_name, - "salutation": self.salutation, - "image": self.image or "", - }) - if self.has_value_changed("email"): - contact_doc.email_ids = [] - contact_doc.append("email_ids", {"email_id": self.email, "is_primary": 1}) - - if self.has_value_changed("phone"): - contact_doc.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1}) - - if self.has_value_changed("mobile_no"): - contact_doc.phone_nos = [] - contact_doc.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1}) - - contact_doc.save() - break - else: - self.contact_doc = self.create_contact() - self.link_to_contact() - - def before_insert(self): - self.contact_doc = None - self.contact_doc = self.create_contact() - - def after_insert(self): - self.link_to_contact() - - def link_to_contact(self): - # update contact links - if self.contact_doc: - self.contact_doc.append( - "links", {"link_doctype": "CRM Lead", "link_name": self.name, "link_title": self.lead_name} - ) - self.contact_doc.save() - + def create_contact(self): if not self.lead_name: self.set_full_name() self.set_lead_name() + if self.contact_exists(): + return + contact = frappe.new_doc("Contact") contact.update( { @@ -125,7 +80,40 @@ def create_contact(self): contact.insert(ignore_permissions=True) contact.reload() # load changes by hooks on contact - return contact + return contact.name + + def contact_exists(self): + email_exist = frappe.db.exists("Contact Email", {"email_id": self.email}) + phone_exist = frappe.db.exists("Contact Phone", {"phone": self.phone}) + mobile_exist = frappe.db.exists("Contact Phone", {"phone": self.mobile_no}) + + if email_exist or phone_exist or mobile_exist: + + text = "Email" if email_exist else "Phone" if phone_exist else "Mobile No" + data = self.email if email_exist else self.phone if phone_exist else self.mobile_no + + value = "{0}: {1}".format(text, data) + + frappe.throw( + _("Contact already exists with {0}").format(value), + title=_("Contact Already Exists"), + ) + return True + + return False + + def create_deal(self, contact): + deal = frappe.new_doc("CRM Deal") + deal.update( + { + "lead": self.name, + "organization": self.organization, + "deal_owner": self.lead_owner, + "contacts": [{"contact": contact}], + } + ) + deal.insert(ignore_permissions=True) + return deal.name @staticmethod def sort_options(): @@ -140,4 +128,17 @@ def sort_options(): { "label": 'Last Name', "value": 'last_name' }, { "label": 'Email', "value": 'email' }, { "label": 'Mobile no', "value": 'mobile_no' }, - ] \ No newline at end of file + ] + +@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 \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_organization/crm_organization.json b/crm/fcrm/doctype/crm_organization/crm_organization.json index eee059190..f048fc34e 100644 --- a/crm/fcrm/doctype/crm_organization/crm_organization.json +++ b/crm/fcrm/doctype/crm_organization/crm_organization.json @@ -11,7 +11,6 @@ "organization_logo", "column_break_pnpp", "website", - "job_title", "annual_revenue", "industry" ], @@ -43,11 +42,6 @@ "fieldname": "column_break_pnpp", "fieldtype": "Column Break" }, - { - "fieldname": "job_title", - "fieldtype": "Data", - "label": "Job Title" - }, { "fieldname": "annual_revenue", "fieldtype": "Currency", @@ -63,7 +57,7 @@ "image_field": "organization_logo", "index_web_pages_for_search": 1, "links": [], - "modified": "2023-11-06 15:28:26.610882", + "modified": "2023-11-13 13:32:39.029742", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Organization", diff --git a/crm/hooks.py b/crm/hooks.py index 744e09195..fdaef00f9 100644 --- a/crm/hooks.py +++ b/crm/hooks.py @@ -125,13 +125,11 @@ # --------------- # Hook on document methods and events -# doc_events = { -# "*": { -# "on_update": "method", -# "on_cancel": "method", -# "on_trash": "method" -# } -# } +doc_events = { + "Contact": { + "validate": ["crm.api.contact.validate"], + }, +} # Scheduled Tasks # --------------- diff --git a/frontend/src/components/Controls/Link.vue b/frontend/src/components/Controls/Link.vue new file mode 100644 index 000000000..1cb2e40e5 --- /dev/null +++ b/frontend/src/components/Controls/Link.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/src/components/DropdownItem.vue b/frontend/src/components/DropdownItem.vue new file mode 100644 index 000000000..5460ff4b4 --- /dev/null +++ b/frontend/src/components/DropdownItem.vue @@ -0,0 +1,41 @@ + + diff --git a/frontend/src/components/Icons/SuccessIcon.vue b/frontend/src/components/Icons/SuccessIcon.vue new file mode 100644 index 000000000..831786e61 --- /dev/null +++ b/frontend/src/components/Icons/SuccessIcon.vue @@ -0,0 +1,16 @@ + diff --git a/frontend/src/components/ListViews/ContactsListView.vue b/frontend/src/components/ListViews/ContactsListView.vue index 238fe3858..9540cc052 100644 --- a/frontend/src/components/ListViews/ContactsListView.vue +++ b/frontend/src/components/ListViews/ContactsListView.vue @@ -4,6 +4,7 @@ :rows="rows" :options="{ getRowRoute: (row) => ({ name: 'Contact', params: { contactId: row.name } }), + selectable: options.selectable, }" row-key="name" > @@ -70,5 +71,11 @@ const props = defineProps({ type: Array, required: true, }, + options: { + type: Object, + default: () => ({ + selectable: true, + }), + }, }) diff --git a/frontend/src/components/ListViews/DealsListView.vue b/frontend/src/components/ListViews/DealsListView.vue index 8824466b6..576e43084 100644 --- a/frontend/src/components/ListViews/DealsListView.vue +++ b/frontend/src/components/ListViews/DealsListView.vue @@ -4,6 +4,7 @@ :rows="rows" :options="{ getRowRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }), + selectable: options.selectable, }" row-key="name" > @@ -75,5 +76,11 @@ const props = defineProps({ type: Array, required: true, }, + options: { + type: Object, + default: () => ({ + selectable: true, + }), + }, }) diff --git a/frontend/src/components/ListViews/LeadsListView.vue b/frontend/src/components/ListViews/LeadsListView.vue index ccf4b0c49..e28c57629 100644 --- a/frontend/src/components/ListViews/LeadsListView.vue +++ b/frontend/src/components/ListViews/LeadsListView.vue @@ -4,6 +4,7 @@ :rows="rows" :options="{ getRowRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }), + selectable: options.selectable, }" row-key="name" > @@ -84,5 +85,11 @@ const props = defineProps({ type: Array, required: true, }, + options: { + type: Object, + default: () => ({ + selectable: true, + }), + }, }) diff --git a/frontend/src/components/Modals/ContactModal.vue b/frontend/src/components/Modals/ContactModal.vue index 5fd0d6cec..9de9fff0e 100644 --- a/frontend/src/components/Modals/ContactModal.vue +++ b/frontend/src/components/Modals/ContactModal.vue @@ -9,19 +9,21 @@ label: editMode ? 'Update' : 'Create', variant: 'solid', disabled: !dirty, - onClick: ({ close }) => updateContact(close), + onClick: ({ close }) => + editMode ? updateContact(close) : callInsertDoc(close), }, ], }" > diff --git a/frontend/src/pages/Deal.vue b/frontend/src/pages/Deal.vue index aa86727b9..42dd6ffaa 100644 --- a/frontend/src/pages/Deal.vue +++ b/frontend/src/pages/Deal.vue @@ -8,7 +8,7 @@ type="autocomplete" :options="activeAgents" :value="getUser(deal.data.deal_owner).full_name" - @change="(option) => updateAssignedAgent(option.email)" + @change="(option) => updateField('deal_owner', option.email)" placeholder="Deal owner" > - +