From 3df4f8898b82158537c2f182c36c3abdf2abc0e0 Mon Sep 17 00:00:00 2001 From: Anand Doshi Date: Fri, 4 Oct 2013 21:19:30 +0530 Subject: [PATCH 1/3] [minor] crm funnel first cut --- selling/page/crm_funnel/__init__.py | 0 selling/page/crm_funnel/crm_funnel.css | 3 + selling/page/crm_funnel/crm_funnel.js | 176 +++++++++++++++++++++++++ selling/page/crm_funnel/crm_funnel.py | 33 +++++ selling/page/crm_funnel/crm_funnel.txt | 33 +++++ 5 files changed, 245 insertions(+) create mode 100644 selling/page/crm_funnel/__init__.py create mode 100644 selling/page/crm_funnel/crm_funnel.css create mode 100644 selling/page/crm_funnel/crm_funnel.js create mode 100644 selling/page/crm_funnel/crm_funnel.py create mode 100644 selling/page/crm_funnel/crm_funnel.txt diff --git a/selling/page/crm_funnel/__init__.py b/selling/page/crm_funnel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selling/page/crm_funnel/crm_funnel.css b/selling/page/crm_funnel/crm_funnel.css new file mode 100644 index 0000000000..89e904fcfc --- /dev/null +++ b/selling/page/crm_funnel/crm_funnel.css @@ -0,0 +1,3 @@ +.funnel-wrapper { + margin: 15px; +} \ No newline at end of file diff --git a/selling/page/crm_funnel/crm_funnel.js b/selling/page/crm_funnel/crm_funnel.js new file mode 100644 index 0000000000..a2d2b93479 --- /dev/null +++ b/selling/page/crm_funnel/crm_funnel.js @@ -0,0 +1,176 @@ +// Copyright (c) 2013, Web Notes Technologies Pvt. Ltd. +// License: GNU General Public License v3. See license.txt + +wn.pages['crm-funnel'].onload = function(wrapper) { + wn.ui.make_app_page({ + parent: wrapper, + title: 'CRM Funnel', + single_column: true + }); + + wrapper.crm_funnel = new erpnext.CRMFunnel(wrapper); +} + +erpnext.CRMFunnel = Class.extend({ + init: function(wrapper) { + var me = this; + // 0 setTimeout hack - this gives time for canvas to get width and height + setTimeout(function() { + me.setup(wrapper); + me.get_data(); + }, 0); + }, + + setup: function(wrapper) { + var me = this; + + this.elements = { + layout: $(wrapper).find(".layout-main"), + from_date: wrapper.appframe.add_date("From Date"), + to_date: wrapper.appframe.add_date("To Date"), + refresh_btn: wrapper.appframe.add_button("Refresh", + function() { me.get_data(); }, "icon-refresh"), + }; + + this.elements.no_data = $('
No Data
') + .toggle(false) + .appendTo(this.elements.layout); + + this.elements.funnel_wrapper = $('
') + .appendTo(this.elements.layout); + + this.options = { + from_date: wn.datetime.get_today(), + to_date: wn.datetime.add_months(wn.datetime.get_today(), -1) + }; + + // set defaults and bind on change + $.each(this.options, function(k, v) { + me.elements[k].val(wn.datetime.str_to_user(v)); + me.elements[k].on("change", function() { + me.options[k] = wn.datetime.user_to_str($(this).val()); + me.get_data(); + }); + }); + + // bind refresh + this.elements.refresh_btn.on("click", function() { + me.get_data(this); + }); + + // bind resize + $(window).resize(function() { + me.render(); + }); + }, + + get_data: function(btn) { + var me = this; + wn.call({ + module: "selling", + page: "crm_funnel", + method: "get_funnel_data", + args: { + from_date: this.options.from_date, + to_date: this.options.to_date + }, + btn: btn, + callback: function(r) { + if(!r.exc) { + me.options.data = r.message; + me.render(); + } + } + }); + }, + + render: function() { + var me = this; + this.prepare(); + + var context = this.elements.context, + x_start = 0.0, + x_end = this.options.width, + x_mid = (x_end - x_start) / 2.0, + y = 0, + y_old = 0.0; + + if(this.options.total_value === 0) { + this.elements.no_data.toggle(true); + return; + } + + this.options.data.forEach(function(d) { + context.fillStyle = d.color; + context.strokeStyle = d.color; + me.draw_triangle(x_start, x_mid, x_end, y, me.options.height); + + y_old = y; + + // new y + y = y + (me.options.height * d.value / me.options.total_value); + + // new x + var half_side = (me.options.height - y) / Math.sqrt(3); + x_start = x_mid - half_side; + x_end = x_mid + half_side; + + var y_mid = y_old + (y - y_old) / 2.0; + + me.draw_legend(x_mid, y_mid, me.options.width, me.options.height, d.value + " - " + d.title); + }); + }, + + prepare: function() { + this.elements.no_data.toggle(false); + + // calculate width and height options + this.options.width = $(this.elements.funnel_wrapper).width() * 2.0 / 3.0; + this.options.height = (Math.sqrt(3) * this.options.width) / 2.0; + + // calculate total value + this.options.total_value = this.options.data.reduce( + function(prev, curr) { return prev + curr.value; }, 0.0); + + this.elements.canvas = $('') + .appendTo(this.elements.funnel_wrapper.empty()) + .attr("width", $(this.elements.funnel_wrapper).width()) + .attr("height", this.options.height); + + this.elements.context = this.elements.canvas.get(0).getContext("2d"); + }, + + draw_triangle: function(x_start, x_mid, x_end, y, height) { + var context = this.elements.context; + context.beginPath(); + context.moveTo(x_start, y); + context.lineTo(x_end, y); + context.lineTo(x_mid, height); + context.lineTo(x_start, y); + context.closePath(); + context.fill(); + }, + + draw_legend: function(x_mid, y_mid, width, height, title) { + var context = this.elements.context; + + // draw line + context.beginPath(); + context.moveTo(x_mid, y_mid); + context.lineTo(width, y_mid); + context.closePath(); + context.stroke(); + + // draw circle + context.beginPath(); + context.arc(width, y_mid, 5, 0, Math.PI * 2, false); + context.closePath(); + context.fill(); + + // draw text + context.fillStyle = "black"; + context.textBaseline = "middle"; + context.font = "1.1em sans-serif"; + context.fillText(title, width + 20, y_mid); + } +}); \ No newline at end of file diff --git a/selling/page/crm_funnel/crm_funnel.py b/selling/page/crm_funnel/crm_funnel.py new file mode 100644 index 0000000000..be0aebe7bb --- /dev/null +++ b/selling/page/crm_funnel/crm_funnel.py @@ -0,0 +1,33 @@ +# Copyright (c) 2013, Web Notes Technologies Pvt. Ltd. +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import webnotes + +@webnotes.whitelist() +def get_funnel_data(from_date, to_date): + active_leads = webnotes.conn.sql("""select count(*) from `tabLead` + where (`modified` between %s and %s) + and status != "Do Not Contact" """, (from_date, to_date))[0][0] + + active_leads += webnotes.conn.sql("""select count(distinct customer) from `tabContact` + where (`modified` between %s and %s) + and status != "Passive" """, (from_date, to_date))[0][0] + + opportunities = webnotes.conn.sql("""select count(*) from `tabOpportunity` + where docstatus = 1 and (`modified` between %s and %s) + and status != "Lost" """, (from_date, to_date))[0][0] + + quotations = webnotes.conn.sql("""select count(*) from `tabQuotation` + where docstatus = 1 and (`modified` between %s and %s) + and status != "Lost" """, (from_date, to_date))[0][0] + + sales_orders = webnotes.conn.sql("""select count(*) from `tabQuotation` + where docstatus = 1 and (`modified` between %s and %s)""", (from_date, to_date))[0][0] + + return [ + { "title": "Active Leads / Customers", "value": active_leads, "color": "#B03B46" }, + { "title": "Opportunities", "value": opportunities, "color": "#F09C00" }, + { "title": "Quotations", "value": quotations, "color": "#006685" }, + { "title": "Sales Orders", "value": sales_orders, "color": "#00AD65" } + ] diff --git a/selling/page/crm_funnel/crm_funnel.txt b/selling/page/crm_funnel/crm_funnel.txt new file mode 100644 index 0000000000..29cf566053 --- /dev/null +++ b/selling/page/crm_funnel/crm_funnel.txt @@ -0,0 +1,33 @@ +[ + { + "creation": "2013-10-04 13:17:18", + "docstatus": 0, + "modified": "2013-10-04 13:17:18", + "modified_by": "Administrator", + "owner": "Administrator" + }, + { + "doctype": "Page", + "icon": "icon-filter", + "module": "Selling", + "name": "__common__", + "page_name": "crm-funnel", + "standard": "Yes", + "title": "CRM Funnel" + }, + { + "doctype": "Page Role", + "name": "__common__", + "parent": "crm-funnel", + "parentfield": "roles", + "parenttype": "Page", + "role": "Sales Manager" + }, + { + "doctype": "Page", + "name": "crm-funnel" + }, + { + "doctype": "Page Role" + } +] \ No newline at end of file From 988096e82d6888544a4703a3f2ff65b4f790a08c Mon Sep 17 00:00:00 2001 From: Anand Doshi Date: Mon, 7 Oct 2013 18:06:52 +0530 Subject: [PATCH 2/3] [feature] Sales Funnel visualization. Go to Selling > Analytics > Sales Funnel --- .../{crm_funnel => sales_funnel}/__init__.py | 0 .../sales_funnel.css} | 0 .../sales_funnel.js} | 29 ++++++++++++++----- .../sales_funnel.py} | 0 .../sales_funnel.txt} | 8 ++--- selling/page/selling_home/selling_home.js | 4 +++ 6 files changed, 29 insertions(+), 12 deletions(-) rename selling/page/{crm_funnel => sales_funnel}/__init__.py (100%) rename selling/page/{crm_funnel/crm_funnel.css => sales_funnel/sales_funnel.css} (100%) rename selling/page/{crm_funnel/crm_funnel.js => sales_funnel/sales_funnel.js} (83%) rename selling/page/{crm_funnel/crm_funnel.py => sales_funnel/sales_funnel.py} (100%) rename selling/page/{crm_funnel/crm_funnel.txt => sales_funnel/sales_funnel.txt} (81%) diff --git a/selling/page/crm_funnel/__init__.py b/selling/page/sales_funnel/__init__.py similarity index 100% rename from selling/page/crm_funnel/__init__.py rename to selling/page/sales_funnel/__init__.py diff --git a/selling/page/crm_funnel/crm_funnel.css b/selling/page/sales_funnel/sales_funnel.css similarity index 100% rename from selling/page/crm_funnel/crm_funnel.css rename to selling/page/sales_funnel/sales_funnel.css diff --git a/selling/page/crm_funnel/crm_funnel.js b/selling/page/sales_funnel/sales_funnel.js similarity index 83% rename from selling/page/crm_funnel/crm_funnel.js rename to selling/page/sales_funnel/sales_funnel.js index a2d2b93479..e2c3a98bf5 100644 --- a/selling/page/crm_funnel/crm_funnel.js +++ b/selling/page/sales_funnel/sales_funnel.js @@ -1,14 +1,18 @@ // Copyright (c) 2013, Web Notes Technologies Pvt. Ltd. // License: GNU General Public License v3. See license.txt -wn.pages['crm-funnel'].onload = function(wrapper) { +wn.pages['sales-funnel'].onload = function(wrapper) { wn.ui.make_app_page({ parent: wrapper, - title: 'CRM Funnel', + title: 'Sales Funnel', single_column: true }); wrapper.crm_funnel = new erpnext.CRMFunnel(wrapper); + + wrapper.appframe.add_module_icon("Selling", "sales-funnel", function() { + wn.set_route("selling-home"); + }); } erpnext.CRMFunnel = Class.extend({ @@ -40,8 +44,8 @@ erpnext.CRMFunnel = Class.extend({ .appendTo(this.elements.layout); this.options = { - from_date: wn.datetime.get_today(), - to_date: wn.datetime.add_months(wn.datetime.get_today(), -1) + from_date: wn.datetime.add_months(wn.datetime.get_today(), -1), + to_date: wn.datetime.get_today() }; // set defaults and bind on change @@ -108,7 +112,7 @@ erpnext.CRMFunnel = Class.extend({ y_old = y; // new y - y = y + (me.options.height * d.value / me.options.total_value); + y = y + d.height; // new x var half_side = (me.options.height - y) / Math.sqrt(3); @@ -122,15 +126,24 @@ erpnext.CRMFunnel = Class.extend({ }, prepare: function() { + var me = this; + this.elements.no_data.toggle(false); // calculate width and height options this.options.width = $(this.elements.funnel_wrapper).width() * 2.0 / 3.0; this.options.height = (Math.sqrt(3) * this.options.width) / 2.0; - // calculate total value - this.options.total_value = this.options.data.reduce( - function(prev, curr) { return prev + curr.value; }, 0.0); + // calculate total weightage + // as height decreases, area decreases by the square of the reduction + // hence, compensating by squaring the index value + this.options.total_weightage = this.options.data.reduce( + function(prev, curr, i) { return prev + Math.pow(i+1, 2) * curr.value; }, 0.0); + + // calculate height for each data + $.each(this.options.data, function(i, d) { + d.height = me.options.height * d.value * Math.pow(i+1, 2) / me.options.total_weightage; + }); this.elements.canvas = $('') .appendTo(this.elements.funnel_wrapper.empty()) diff --git a/selling/page/crm_funnel/crm_funnel.py b/selling/page/sales_funnel/sales_funnel.py similarity index 100% rename from selling/page/crm_funnel/crm_funnel.py rename to selling/page/sales_funnel/sales_funnel.py diff --git a/selling/page/crm_funnel/crm_funnel.txt b/selling/page/sales_funnel/sales_funnel.txt similarity index 81% rename from selling/page/crm_funnel/crm_funnel.txt rename to selling/page/sales_funnel/sales_funnel.txt index 29cf566053..b841f20fdc 100644 --- a/selling/page/crm_funnel/crm_funnel.txt +++ b/selling/page/sales_funnel/sales_funnel.txt @@ -11,21 +11,21 @@ "icon": "icon-filter", "module": "Selling", "name": "__common__", - "page_name": "crm-funnel", + "page_name": "sales-funnel", "standard": "Yes", - "title": "CRM Funnel" + "title": "Sales Funnel" }, { "doctype": "Page Role", "name": "__common__", - "parent": "crm-funnel", + "parent": "sales-funnel", "parentfield": "roles", "parenttype": "Page", "role": "Sales Manager" }, { "doctype": "Page", - "name": "crm-funnel" + "name": "sales-funnel" }, { "doctype": "Page Role" diff --git a/selling/page/selling_home/selling_home.js b/selling/page/selling_home/selling_home.js index 9697ccf985..5dd33e640f 100644 --- a/selling/page/selling_home/selling_home.js +++ b/selling/page/selling_home/selling_home.js @@ -155,6 +155,10 @@ wn.module_page["Selling"] = [ "label":wn._("Sales Analytics"), page: "sales-analytics" }, + { + "label":wn._("Sales Funnel"), + page: "sales-funnel" + }, ] }, { From 8e65dae35cf700797baa240f83a82ee6d6914ea5 Mon Sep 17 00:00:00 2001 From: Anand Doshi Date: Mon, 7 Oct 2013 19:25:50 +0530 Subject: [PATCH 3/3] [minor] [cleanup] add roles in setup control --- setup/doctype/setup_control/setup_control.py | 41 +++++++------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/setup/doctype/setup_control/setup_control.py b/setup/doctype/setup_control/setup_control.py index b78bfcc7d6..76818f0b4d 100644 --- a/setup/doctype/setup_control/setup_control.py +++ b/setup/doctype/setup_control/setup_control.py @@ -4,17 +4,16 @@ from __future__ import unicode_literals import webnotes -from webnotes.utils import cint, cstr, getdate, now, nowdate, get_defaults -from webnotes.model.doc import Document, addchild -from webnotes.model.code import get_obj -from webnotes import session, form, msgprint +from webnotes.utils import cint, cstr, getdate, nowdate, get_defaults +from webnotes.model.doc import Document +from webnotes import msgprint class DocType: def __init__(self, d, dl): self.doc, self.doclist = d, dl def setup_account(self, args): - import webnotes, json + import json if isinstance(args, basestring): args = json.loads(args) webnotes.conn.begin() @@ -175,7 +174,6 @@ class DocType: system_managers = get_system_managers() if not system_managers: return - from webnotes.model.doc import Document for company in companies_list: if company and company[0]: edigest = webnotes.bean({ @@ -216,28 +214,19 @@ class DocType: abbr = cstr(curr_year)[-2:] + '-' + cstr(curr_year+1)[-2:] return fy, stdt, abbr - def create_profile(self, user_email, user_fname, user_lname, pwd=None): - pr = Document('Profile') - pr.first_name = user_fname - pr.last_name = user_lname - pr.name = pr.email = user_email - pr.enabled = 1 - pr.save(1) - if pwd: - webnotes.conn.sql("""insert into __Auth (user, `password`) - values (%s, password(%s)) - on duplicate key update `password`=password(%s)""", - (user_email, pwd, pwd)) - - add_all_roles_to(pr.name) - -def add_all_roles_to(name): - profile = webnotes.doc("Profile", name) +def add_all_roles_to(profile): + if isinstance(profile, basestring): + profile = webnotes.bean("Profile", profile) + for role in webnotes.conn.sql("""select name from tabRole"""): if role[0] not in ["Administrator", "Guest", "All", "Customer", "Supplier", "Partner"]: - d = profile.addchild("user_roles", "UserRole") - d.role = role[0] - d.insert() + profile.doclist.append({ + "doctype": "UserRole", + "parentfield": "user_roles", + "role": role[0] + }) + + profile.save() def create_territories(): """create two default territories, one for home country and one named Rest of the World"""