diff --git a/frappe/desk/doctype/event/test_records.json b/frappe/desk/doctype/event/test_records.json index aaadc881b8..41d5803083 100644 --- a/frappe/desk/doctype/event/test_records.json +++ b/frappe/desk/doctype/event/test_records.json @@ -3,18 +3,21 @@ "doctype": "Event", "subject":"_Test Event 1", "starts_on": "2014-01-01", - "event_type": "Public" + "event_type": "Public", + "creation": "2014-01-01" }, { "doctype": "Event", - "starts_on": "2014-01-01", "subject":"_Test Event 2", - "event_type": "Private" + "starts_on": "2014-01-01", + "event_type": "Private", + "creation": "2014-01-01" }, { "doctype": "Event", - "starts_on": "2014-01-01", "subject": "_Test Event 3", - "event_type": "Private" + "starts_on": "2014-02-01", + "event_type": "Private", + "creation": "2014-02-01" } ] diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 3924afd7a4..d0ee87a209 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -13,10 +13,12 @@ def get_notifications(): return config = get_notification_config() + groups = config.get("for_doctype").keys() + config.get("for_module").keys() cache = frappe.cache() notification_count = {} + notification_percent = {} for name in groups: count = cache.hget("notification_count:" + name, frappe.session.user) @@ -27,6 +29,7 @@ def get_notifications(): "open_count_doctype": get_notifications_for_doctypes(config, notification_count), "open_count_module": get_notifications_for_modules(config, notification_count), "open_count_other": get_notifications_for_other(config, notification_count), + "targets": get_notifications_for_targets(config, notification_percent), "new_messages": get_new_messages() } @@ -111,6 +114,49 @@ def get_notifications_for_doctypes(config, notification_count): return open_count_doctype +def get_notifications_for_targets(config, notification_percent): + """Notifications for doc targets""" + can_read = frappe.get_user().get_can_read() + doc_target_percents = {} + + # doc_target_percents = { + # "Company": { + # "Acme": 87, + # "RobotsRUs": 50, + # }, {}... + # } + + for doctype in config.targets: + if doctype in can_read: + if doctype in notification_percent: + doc_target_percents[doctype] = notification_percent[doctype] + else: + doc_target_percents[doctype] = {} + d = config.targets[doctype] + condition = d["filters"] + target_field = d["target_field"] + value_field = d["value_field"] + try: + if isinstance(condition, dict): + doc_list = frappe.get_list(doctype, fields=["name", target_field, value_field], + filters=condition, limit_page_length = 100, ignore_ifnull=True) + + except frappe.PermissionError: + frappe.clear_messages() + pass + except Exception as e: + if e.args[0]!=1412: + raise + + else: + for doc in doc_list: + value = doc[value_field] + target = doc[target_field] + doc_target_percents[doctype][doc.name] = (value/target * 100) if value < target else 100 + + return doc_target_percents + + def clear_notifications(user=None): if frappe.flags.in_install: return @@ -163,7 +209,7 @@ def get_notification_config(): config = frappe._dict() for notification_config in frappe.get_hooks().notification_config: nc = frappe.get_attr(notification_config)() - for key in ("for_doctype", "for_module", "for_other"): + for key in ("for_doctype", "for_module", "for_other", "targets"): config.setdefault(key, {}) config[key].update(nc.get(key, {})) return config diff --git a/frappe/docs/assets/img/desk/bar_graph.png b/frappe/docs/assets/img/desk/bar_graph.png new file mode 100644 index 0000000000..d25254af6d Binary files /dev/null and b/frappe/docs/assets/img/desk/bar_graph.png differ diff --git a/frappe/docs/assets/img/desk/line_graph.png b/frappe/docs/assets/img/desk/line_graph.png new file mode 100644 index 0000000000..02c60c7c18 Binary files /dev/null and b/frappe/docs/assets/img/desk/line_graph.png differ diff --git a/frappe/docs/user/en/guides/desk/making_graphs.md b/frappe/docs/user/en/guides/desk/making_graphs.md new file mode 100644 index 0000000000..9234fa58b4 --- /dev/null +++ b/frappe/docs/user/en/guides/desk/making_graphs.md @@ -0,0 +1,61 @@ +# Making Graphs + +The Frappe UI **Graph** object enables you to render simple line and bar graphs for a discreet set of data points. You can also set special checkpoint values and summary stats. + +### Example: Line graph +Here's is an example of a simple sales graph: + + render_graph: function() { + $('.form-graph').empty(); + + var months = ['Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul']; + var values = [2410, 3100, 1700, 1200, 2700, 1600, 2740, 1000, 850, 1500, 400, 2013]; + + var goal = 2500; + var current_val = 2013; + + new frappe.ui.Graph({ + parent: $('.form-graph'), + width: 700, + height: 140, + mode: 'line-graph', + + title: 'Sales', + subtitle: 'Monthly', + y_values: values, + x_points: months, + + specific_values: [ + { + name: "Goal", + line_type: "dashed", // "dashed" or "solid" + value: goal + }, + ], + summary_values: [ + { + name: "This month", + color: 'green', // Indicator colors: 'grey', 'blue', 'red', + // 'green', 'orange', 'purple', 'darkgrey', + // 'black', 'yellow', 'lightblue' + value: '₹ ' + current_val + }, + { + name: "Goal", + color: 'blue', + value: '₹ ' + goal + }, + { + name: "Completed", + color: 'green', + value: (current_val/goal*100).toFixed(1) + "%" + } + ] + }); + }, + + + +Setting the mode to 'bar-graph': + + diff --git a/frappe/public/build.json b/frappe/public/build.json index 75e4e76469..3b58de727b 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -161,6 +161,7 @@ "public/js/frappe/query_string.js", "public/js/frappe/ui/charts.js", + "public/js/frappe/ui/graph.js", "public/js/frappe/misc/rating_icons.html", "public/js/frappe/feedback.js" diff --git a/frappe/public/css/desk.css b/frappe/public/css/desk.css index fa13c421fa..ebe34f0de2 100644 --- a/frappe/public/css/desk.css +++ b/frappe/public/css/desk.css @@ -508,6 +508,17 @@ fieldset[disabled] .form-control { cursor: pointer; margin-right: 10px; } +a.progress-small .progress-chart { + width: 60px; + margin-top: 4px; + float: right; +} +a.progress-small .progress { + margin-bottom: 0; +} +a.progress-small .progress-bar { + background-color: #98d85b; +} /* on small screens, show only icons on top */ @media (max-width: 767px) { .module-view-layout .nav-stacked > li { diff --git a/frappe/public/css/form.css b/frappe/public/css/form.css index d822b04975..844c2dc761 100644 --- a/frappe/public/css/form.css +++ b/frappe/public/css/form.css @@ -642,6 +642,92 @@ select.form-control { box-shadow: none; } } +/* goals */ +.goals-page-container { + background-color: #fafbfc; + padding-top: 1px; +} +.goals-page-container .goal-container { + background-color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + border-radius: 2px; + padding: 10px; + margin: 10px; +} +.graph-container .graphics { + margin-top: 10px; + padding: 10px 0px; +} +.graph-container .stats-group { + display: flex; + justify-content: space-around; + flex: 1; +} +.graph-container .stats-container { + display: flex; + justify-content: space-around; +} +.graph-container .stats-container .stats { + padding-bottom: 15px; +} +.graph-container .stats-container .stats-title { + color: #8D99A6; +} +.graph-container .stats-container .stats-value { + font-size: 20px; + font-weight: 300; +} +.graph-container .stats-container .stats-description { + font-size: 12px; + color: #8D99A6; +} +.graph-container .stats-container .graph-data .stats-value { + color: #98d85b; +} +.bar-graph .axis, +.line-graph .axis { + font-size: 10px; + fill: #6a737d; +} +.bar-graph .axis line, +.line-graph .axis line { + stroke: rgba(27, 31, 35, 0.1); +} +.data-points circle { + fill: #28a745; + stroke: #fff; + stroke-width: 2; +} +.data-points g.mini { + fill: #98d85b; +} +.data-points path { + fill: none; + stroke: #28a745; + stroke-opacity: 1; + stroke-width: 2px; +} +.line-graph .path { + fill: none; + stroke: #28a745; + stroke-opacity: 1; + stroke-width: 2px; +} +line.dashed { + stroke-dasharray: 5,3; +} +.tick.x-axis-label { + display: block; +} +.tick .specific-value { + text-anchor: start; +} +.tick .y-value-text { + text-anchor: end; +} +.tick .x-value-text { + text-anchor: middle; +} body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] { height: 80px !important; } diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index fc3c31fce2..36baa6ca15 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -11,7 +11,7 @@ frappe.ui.form.Dashboard = Class.extend({ this.progress_area = this.wrapper.find(".progress-area"); this.heatmap_area = this.wrapper.find('.form-heatmap'); - this.chart_area = this.wrapper.find('.form-chart'); + this.graph_area = this.wrapper.find('.form-graph'); this.stats_area = this.wrapper.find('.form-stats'); this.stats_area_row = this.stats_area.find('.row'); this.links_area = this.wrapper.find('.form-links'); @@ -43,9 +43,9 @@ frappe.ui.form.Dashboard = Class.extend({ this.frm.layout.show_message(); }, - add_comment: function(text, permanent) { + add_comment: function(text, alert_class, permanent) { var me = this; - this.set_headline_alert(text); + this.set_headline_alert(text, alert_class); if(!permanent) { setTimeout(function() { me.clear_headline(); @@ -91,6 +91,7 @@ frappe.ui.form.Dashboard = Class.extend({ this.show(); }, + format_percent: function(title, percent) { var width = cint(percent) < 1 ? 1 : cint(percent); var progress_class = ""; @@ -138,6 +139,11 @@ frappe.ui.form.Dashboard = Class.extend({ show = true; } + if(this.data.graph) { + this.setup_graph(); + show = true; + } + if(show) { this.show(); } @@ -383,13 +389,50 @@ frappe.ui.form.Dashboard = Class.extend({ }, //graphs + setup_graph: function() { + var me = this; + + var method = this.data.graph_method; + var args = { + doctype: this.frm.doctype, + docname: this.frm.doc.name, + }; + + $.extend(args, this.data.graph_method_args); + + frappe.call({ + type: "GET", + method: method, + args: args, + + callback: function(r) { + if(r.message) { + me.render_graph(r.message); + } + } + }); + }, + + render_graph: function(args) { + var me = this; + this.graph_area.empty().removeClass('hidden'); + $.extend(args, { + parent: me.graph_area, + width: 700, + height: 140, + mode: 'line-graph' + }); + + new frappe.ui.Graph(args); + }, + setup_chart: function(opts) { var me = this; - this.chart_area.removeClass('hidden'); + this.graph_area.removeClass('hidden'); $.extend(opts, { - wrapper: me.wrapper.find('.form-chart'), + wrapper: me.graph_area, padding: { right: 30, bottom: 30 diff --git a/frappe/public/js/frappe/form/templates/form_dashboard.html b/frappe/public/js/frappe/form/templates/form_dashboard.html index b1865a9c94..c41929df73 100644 --- a/frappe/public/js/frappe/form/templates/form_dashboard.html +++ b/frappe/public/js/frappe/form/templates/form_dashboard.html @@ -5,7 +5,7 @@
- + diff --git a/frappe/public/js/frappe/ui/graph.js b/frappe/public/js/frappe/ui/graph.js new file mode 100644 index 0000000000..25718024a1 --- /dev/null +++ b/frappe/public/js/frappe/ui/graph.js @@ -0,0 +1,308 @@ +// specific_values = [ +// { +// name: "Average", +// line_type: "dashed", // "dashed" or "solid" +// value: 10 +// }, + +// summary_values = [ +// { +// name: "Total", +// color: 'blue', // Indicator colors: 'grey', 'blue', 'red', 'green', 'orange', +// // 'purple', 'darkgrey', 'black', 'yellow', 'lightblue' +// value: 80 +// } +// ] + +frappe.ui.Graph = class Graph { + constructor({ + parent = null, + + width = 0, height = 0, + title = '', subtitle = '', + + y_values = [], + x_points = [], + + specific_values = [], + summary_values = [], + + color = '', + mode = '', + } = {}) { + + if(Object.getPrototypeOf(this) === frappe.ui.Graph.prototype) { + if(mode === 'line-graph') { + return new frappe.ui.LineGraph(arguments[0]); + } else if(mode === 'bar-graph') { + return new frappe.ui.BarGraph(arguments[0]); + } + } + + this.parent = parent; + + this.width = width; + this.height = height; + + this.title = title; + this.subtitle = subtitle; + + this.y_values = y_values; + this.x_points = x_points; + + this.specific_values = specific_values; + this.summary_values = summary_values; + + this.color = color; + this.mode = mode; + + this.$graph = null; + + frappe.require("assets/frappe/js/lib/snap.svg-min.js", this.setup.bind(this)); + } + + setup() { + this.setup_container(); + this.refresh(); + } + + refresh() { + this.setup_values(); + this.setup_components(); + this.make_y_axis(); + this.make_x_axis(); + this.make_units(); + if(this.specific_values.length > 0) { + this.show_specific_values(); + } + this.setup_group(); + + if(this.summary_values.length > 0) { + this.show_summary(); + } + } + + setup_container() { + this.container = $('
') + .addClass('graph-container') + .append($(`
${this.title}
`)) + .append($(`
${this.subtitle}
`)) + .append($(`
`)) + .append($(`
`)) + .appendTo(this.parent); + + let $graphics = this.container.find('.graphics'); + this.$stats_container = this.container.find('.stats-container'); + + this.$graph = $('
') + .addClass(this.mode) + .appendTo($graphics); + + this.$svg = $(``); + this.$graph.append(this.$svg); + + this.snap = new Snap(this.$svg[0]); + } + + setup_values() { + this.upper_graph_bound = this.get_upper_limit_and_parts(this.y_values)[0]; + this.y_axis = this.get_y_axis(this.y_values); + this.avg_unit_width = (this.width-50)/(this.x_points.length - 1); + } + + setup_components() { + this.y_axis_group = this.snap.g().attr({ + class: "y axis" + }); + + this.x_axis_group = this.snap.g().attr({ + class: "x axis" + }); + + this.graph_list = this.snap.g().attr({ + class: "data-points", + }); + + this.specific_y_lines = this.snap.g().attr({ + class: "specific axis", + }); + } + + setup_group() { + this.snap.g( + this.y_axis_group, + this.x_axis_group, + this.graph_list, + this.specific_y_lines + ).attr({ + transform: "translate(40, 10)" // default + }); + } + + show_specific_values() { + this.specific_values.map(d => { + this.specific_y_lines.add(this.snap.g( + this.snap.line(0, 0, this.width - 50, 0).attr({ + class: d.line_type === "dashed" ? "dashed": "" + }), + this.snap.text(this.width - 100, 0, d.name.toUpperCase()).attr({ + dy: ".32em", + class: "specific-value", + }) + ).attr({ + class: "tick", + transform: `translate(0, ${100 - 100/(this.upper_graph_bound/d.value) })` + })); + }); + } + + show_summary() { + this.summary_values.map(d => { + this.$stats_container.append($(`
+ ${d.name}: ${d.value} +
`)); + }); + } + + // Helpers + get_upper_limit_and_parts(array) { + let specific_values = this.specific_values.map(d => d.value); + let max_val = Math.max(...array, ...specific_values); + if((max_val+"").length <= 1) { + return [10, 5]; + } else { + let multiplier = Math.pow(10, ((max_val+"").length - 1)); + let significant = Math.ceil(max_val/multiplier); + if(significant % 2 !== 0) significant++; + let parts = (significant < 5) ? significant : significant/2; + return [significant * multiplier, parts]; + } + } + + get_y_axis(array) { + let upper_limit, parts; + [upper_limit, parts] = this.get_upper_limit_and_parts(array); + let y_axis = []; + for(var i = 0; i <= parts; i++){ + y_axis.push(upper_limit / parts * i); + } + return y_axis; + } +}; + +frappe.ui.BarGraph = class BarGraph extends frappe.ui.Graph { + constructor(args = {}) { + super(args); + } + + setup_values() { + super.setup_values(); + this.avg_unit_width = (this.width-50)/(this.x_points.length + 2); + } + + make_y_axis() { + this.y_axis.map((point) => { + this.y_axis_group.add(this.snap.g( + this.snap.line(0, 0, this.width, 0), + this.snap.text(-3, 0, point+"").attr({ + dy: ".32em", + class: "y-value-text" + }) + ).attr({ + class: "tick", + transform: `translate(0, ${100 - (100/(this.y_axis.length-1) * this.y_axis.indexOf(point)) })` + })); + }); + } + + make_x_axis() { + this.x_axis_group.attr({ + transform: "translate(0,100)" + }); + this.x_points.map((point, i) => { + this.x_axis_group.add(this.snap.g( + this.snap.line(0, 0, 0, 6), + this.snap.text(0, 9, point).attr({ + dy: ".71em", + class: "x-value-text" + }) + ).attr({ + class: "tick x-axis-label", + transform: `translate(${ ((this.avg_unit_width - 5)*3/2) + i * (this.avg_unit_width + 5) }, 0)` + })); + }); + } + + make_units() { + this.y_values.map((value, i) => { + this.graph_list.add(this.snap.g( + this.snap.rect( + 0, + (100 - 100/(this.upper_graph_bound/value)), + this.avg_unit_width - 5, + 100/(this.upper_graph_bound/value) + ) + ).attr({ + class: "bar mini", + transform: `translate(${ (this.avg_unit_width - 5) + i * (this.avg_unit_width + 5) }, 0)`, + })); + }); + } +}; + +frappe.ui.LineGraph = class LineGraph extends frappe.ui.Graph { + constructor(args = {}) { + super(args); + } + + make_y_axis() { + this.y_axis.map((point) => { + this.y_axis_group.add(this.snap.g( + this.snap.line(0, 0, -6, 0), + this.snap.text(-9, 0, point+"").attr({ + dy: ".32em", + class: "y-value-text" + }) + ).attr({ + class: "tick", + transform: `translate(0, ${100 - (100/(this.y_axis.length-1) + * this.y_axis.indexOf(point)) })` + })); + }); + } + + make_x_axis() { + this.x_axis_group.attr({ + transform: "translate(0,-7)" + }); + this.x_points.map((point, i) => { + this.x_axis_group.add(this.snap.g( + this.snap.line(0, 0, 0, this.height - 25), + this.snap.text(0, this.height - 15, point).attr({ + dy: ".71em", + class: "x-value-text" + }) + ).attr({ + class: "tick", + transform: `translate(${ i * this.avg_unit_width }, 0)` + })); + }); + } + + make_units() { + let points_list = []; + this.y_values.map((value, i) => { + let x = i * this.avg_unit_width; + let y = (100 - 100/(this.upper_graph_bound/value)); + this.graph_list.add(this.snap.circle( x, y, 4)); + points_list.push(x+","+y); + }); + + this.make_path("M"+points_list.join("L")); + } + + make_path(path_str) { + this.graph_list.prepend(this.snap.path(path_str)); + } + +}; diff --git a/frappe/public/js/frappe/ui/toolbar/notifications.js b/frappe/public/js/frappe/ui/toolbar/notifications.js index 9198594d8e..465edf02a0 100644 --- a/frappe/public/js/frappe/ui/toolbar/notifications.js +++ b/frappe/public/js/frappe/ui/toolbar/notifications.js @@ -1,125 +1,112 @@ -frappe.provide("frappe.ui.notifications") - -frappe.ui.notifications.update_notifications = function() { - frappe.ui.notifications.total = 0; - var doctypes = Object.keys(frappe.boot.notification_info.open_count_doctype).sort(); - var modules = Object.keys(frappe.boot.notification_info.open_count_module).sort(); - var other = Object.keys(frappe.boot.notification_info.open_count_other).sort(); - - // clear toolbar / sidebar notifications - frappe.ui.notifications.dropdown_notification = $("#dropdown-notification").empty(); - - // add these first. - frappe.ui.notifications.add_notification("Comment"); - frappe.ui.notifications.add_notification("ToDo"); - frappe.ui.notifications.add_notification("Event"); - - // add other - $.each(other, function(i, name) { - frappe.ui.notifications.add_notification(name, frappe.boot.notification_info.open_count_other); - }); - - - // add a divider - if(frappe.ui.notifications.total) { - var divider = '
  • '; - frappe.ui.notifications.dropdown_notification.append($(divider)); - } - - // add to toolbar and sidebar - $.each(doctypes, function(i, doctype) { - if(!in_list(["ToDo", "Comment", "Event"], doctype)) { - frappe.ui.notifications.add_notification(doctype); - } - }); - - // set click events - $("#dropdown-notification a").on("click", function() { - var doctype = $(this).attr("data-doctype"); - var config = frappe.ui.notifications.config[doctype] || {}; - if (config.route) { - frappe.set_route(config.route); - } else if (config.click) { - config.click(); - } else { - frappe.views.show_open_count_list(this); - } - }); - - // switch colour on the navbar and disable if no notifications - $(".navbar-new-comments") - .html(frappe.ui.notifications.total > 20 ? '20+' : frappe.ui.notifications.total) - .toggleClass("navbar-new-comments-true", frappe.ui.notifications.total ? true : false) - .parent().toggleClass("disabled", frappe.ui.notifications.total ? false : true); - -} - -frappe.ui.notifications.add_notification = function(doctype, notifications_map) { - if(!notifications_map) { - notifications_map = frappe.boot.notification_info.open_count_doctype; - } +frappe.provide("frappe.ui.notifications"); + +frappe.ui.notifications = { + config: { + "ToDo": { label: __("To Do") }, + "Chat": { label: __("Chat"), route: "chat"}, + "Event": { label: __("Calendar"), route: "List/Event/Calendar" }, + "Email": { label: __("Email"), route: "List/Communication/Inbox" }, + "Likes": { label: __("Likes"), + click: function() { + frappe.route_options = { show_likes: true }; + if (frappe.get_route()[0]=="activity") { + frappe.pages['activity'].page.list.refresh(); + } else { + frappe.set_route("activity"); + } + } + }, + }, - var count = notifications_map[doctype]; - if(count) { - var config = frappe.ui.notifications.config[doctype] || {}; - var label = config.label || doctype; - var notification_row = repl('
  • \ - \ - %(count)s \ - %(label)s
  • ', { - label: __(label), - count: count > 20 ? '20+' : count, - data_doctype: doctype + update_notifications: function() { + this.total = 0; + this.dropdown = $("#dropdown-notification").empty(); + this.boot_info = frappe.boot.notification_info; + let defaults = ["Comment", "ToDo", "Event"]; + + this.get_counts(this.boot_info.open_count_doctype, 0, defaults); + this.get_counts(this.boot_info.open_count_other, 1); + + // Target counts are stored for docs per doctype + let targets = { doctypes : {} }, map = this.boot_info.targets; + Object.keys(map).map(doctype => { + Object.keys(map[doctype]).map(doc => { + targets[doc] = map[doctype][doc]; + targets.doctypes[doc] = doctype; }); + }); + this.get_counts(targets, 1, null, ["doctypes"], true); + this.get_counts(this.boot_info.open_count_doctype, + 0, null, defaults); + + this.bind_list(); + + // switch colour on the navbar and disable if no notifications + $(".navbar-new-comments") + .html(this.total > 20 ? '20+' : this.total) + .toggleClass("navbar-new-comments-true", this.total ? true : false) + .parent().toggleClass("disabled", this.total ? false : true); + }, - frappe.ui.notifications.dropdown_notification.append($(notification_row)); - - frappe.ui.notifications.total += count; - } -} + get_counts: function(map, divide, keys, excluded = [], target = false) { + keys = keys ? keys + : Object.keys(map).sort().filter(e => !excluded.includes(e)); + keys.map(key => { + let doc_dt = (map.doctypes) ? map.doctypes[key] : undefined; + if(map[key] > 0) { + this.add_notification(key, map[key], doc_dt, target); + } + }); + if(divide) + this.dropdown.append($('
  • ')); + }, -// default notification config -frappe.ui.notifications.config = { - "ToDo": { label: __("To Do") }, - "Chat": { label: __("Chat"), route: "chat"}, - "Event": { label: __("Calendar"), route: "List/Event/Calendar" }, - "Email": { label: __("Email"), route: "List/Communication/Inbox" }, - "Likes": { - label: __("Likes"), - click: function() { - frappe.route_options = { - show_likes: true - }; + add_notification: function(name, value, doc_dt, target = false) { + let label = this.config[name] ? this.config[name].label : name; + let $list_item = !target + ? $(`
  • ${label} + ${value} +
  • `) + : $(`
  • ${label} +
    +
    +
    +
  • `); + this.dropdown.append($list_item); + if(!target) this.total += value; + }, - if (frappe.get_route()[0]=="activity") { - frappe.pages['activity'].page.list.refresh(); + bind_list: function() { + var me = this; + $("#dropdown-notification a").on("click", function() { + var doctype = $(this).attr("data-doctype"); + var doc = $(this).attr("data-doc"); + if(!doc) { + var config = me.config[doctype] || {}; + if (config.route) { + frappe.set_route(config.route); + } else if (config.click) { + config.click(); + } else { + frappe.ui.notifications.show_open_count_list(doctype); + } } else { - frappe.set_route("activity"); + frappe.set_route("Form", doctype, doc); } - } + }); }, -}; - -frappe.views.show_open_count_list = function(element) { - var doctype = $(element).attr("data-doctype"); - var filters = frappe.ui.notifications.get_filters(doctype); - if(filters) { - frappe.route_options = filters; - } - - var route = frappe.get_route(); - if(route[0]==="List" && route[1]===doctype) { - frappe.pages["List/" + doctype].list_view.refresh(); - } else { - frappe.set_route("List", doctype); - } -} - -frappe.ui.notifications.get_filters = function(doctype) { - var conditions = frappe.boot.notification_info.conditions[doctype]; - - if(conditions && $.isPlainObject(conditions)) { - return conditions; - } -} + show_open_count_list: function(doctype) { + let filters = this.boot_info.conditions[doctype]; + if(filters && $.isPlainObject(filters)) { + frappe.route_options = filters; + } + let route = frappe.get_route(); + if(route[0]==="List" && route[1]===doctype) { + frappe.pages["List/" + doctype].list_view.refresh(); + } else { + frappe.set_route("List", doctype); + } + }, +} \ No newline at end of file diff --git a/frappe/public/js/legacy/form.js b/frappe/public/js/legacy/form.js index 416a7a1f17..b9f0d1538d 100644 --- a/frappe/public/js/legacy/form.js +++ b/frappe/public/js/legacy/form.js @@ -335,7 +335,7 @@ _f.Frm.prototype.refresh_header = function(is_a_different_doc) { ! this.is_dirty() && ! this.is_new() && this.doc.docstatus===0) { - this.dashboard.add_comment(__('Submit this document to confirm'), true); + this.dashboard.add_comment(__('Submit this document to confirm'), 'alert-warning', true); } this.clear_custom_buttons(); diff --git a/frappe/public/less/desk.less b/frappe/public/less/desk.less index ad3011cb9e..45bde29460 100644 --- a/frappe/public/less/desk.less +++ b/frappe/public/less/desk.less @@ -66,8 +66,8 @@ a[disabled="disabled"] { #alert-container .desk-alert { -webkit-box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); - -moz-box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); - box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); + box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); padding: 10px 40px 10px 20px; max-width: 400px; @@ -318,19 +318,35 @@ textarea.form-control { } .open-notification { - position:relative; + position:relative; left: 2px; - display:inline-block; - background:#ff5858; - font-size: @text-medium; - line-height:20px; - padding:0 8px; - color:#fff; - border-radius:10px; + display:inline-block; + background:#ff5858; + font-size: @text-medium; + line-height:20px; + padding:0 8px; + color:#fff; + border-radius:10px; cursor: pointer; margin-right: 10px; } +a.progress-small { + .progress-chart { + width: 60px; + margin-top: 4px; + float: right; + } + + .progress { + margin-bottom: 0; + } + + .progress-bar { + background-color: #98d85b; + } +} + /* on small screens, show only icons on top */ @media (max-width: 767px) { .module-view-layout .nav-stacked > li { @@ -825,7 +841,7 @@ textarea.form-control { } .c3-line { - stroke-width: 3px; + stroke-width: 3px; } .c3-tooltip { @@ -897,10 +913,10 @@ input[type="checkbox"] { // Will not be required after commonifying lists with empty state .multiselect-empty-state{ min-height: 300px; - display: flex; - align-items: center; - justify-content: center; - height: 100%; + display: flex; + align-items: center; + justify-content: center; + height: 100%; } // mozilla doesn't support diff --git a/frappe/public/less/form.less b/frappe/public/less/form.less index 5c67bbee03..5bcf903c3b 100644 --- a/frappe/public/less/form.less +++ b/frappe/public/less/form.less @@ -827,6 +827,123 @@ select.form-control { } } +/* goals */ + +.goals-page-container { + background-color: #fafbfc; + padding-top: 1px; + + .goal-container { + background-color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + border-radius: 2px; + padding: 10px; + margin: 10px; + } +} + +.graph-container { + .graphics { + margin-top: 10px; + padding: 10px 0px; + } + + .stats-group { + display: flex; + justify-content: space-around; + flex: 1; + } + + .stats-container { + display: flex; + justify-content: space-around; + + .stats { + padding-bottom: 15px; + } + + .stats-title { + color: #8D99A6; + } + .stats-value { + font-size: 20px; + font-weight: 300; + } + .stats-description { + font-size: 12px; + color: #8D99A6; + } + .graph-data .stats-value { + color: #98d85b; + } + } +} + +.bar-graph, .line-graph { + + .axis { + font-size: 10px; + fill: #6a737d; + + line { + stroke: rgba(27,31,35,0.1); + } + } +} + +.data-points { + circle { + fill: #28a745; + stroke: #fff; + stroke-width: 2; + } + + g.mini { + fill: #98d85b; + } + + path { + fill: none; + stroke: #28a745; + stroke-opacity: 1; + stroke-width: 2px; + } +} + +.line-graph { + .path { + fill: none; + stroke: #28a745; + stroke-opacity: 1; + stroke-width: 2px; + } +} + +line.dashed { + stroke-dasharray: 5,3; +} + +.tick { + &.x-axis-label { + display: block; + } + + .specific-value { + text-anchor: start; + } + + .y-value-text { + text-anchor: end; + } + + .x-value-text { + text-anchor: middle; + } +} + + body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] { height: 80px !important; } + + diff --git a/frappe/tests/test_goal.py b/frappe/tests/test_goal.py new file mode 100644 index 0000000000..5fe490ab56 --- /dev/null +++ b/frappe/tests/test_goal.py @@ -0,0 +1,34 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import unittest +import frappe + +from frappe.utils.goal import get_monthly_results, get_monthly_goal_graph_data +from frappe.test_runner import make_test_objects +import frappe.utils + +class TestGoal(unittest.TestCase): + def setUp(self): + make_test_objects('Event', reset=True) + + def tearDown(self): + frappe.db.sql('delete from `tabEvent`') + # make_test_objects('Event', reset=True) + frappe.db.commit() + + def test_get_monthly_results(self): + '''Test monthly aggregation values of a field''' + result_dict = get_monthly_results('Event', 'subject', 'creation', 'event_type="Private"', 'count') + + from frappe.utils import today, formatdate + self.assertEquals(result_dict[formatdate(today(), "MM-yyyy")], 2) + + def test_get_monthly_goal_graph_data(self): + '''Test for accurate values in graph data (based on test_get_monthly_results)''' + docname = frappe.get_list('Event', filters = {"subject": ["=", "_Test Event 1"]})[0]["name"] + frappe.db.set_value('Event', docname, 'description', 1) + data = get_monthly_goal_graph_data('Test', 'Event', docname, 'description', 'description', 'description', + 'Event', '', 'description', 'creation', 'starts_on = "2014-01-01"', 'count') + self.assertEquals(float(data['y_values'][-1]), 1) diff --git a/frappe/utils/goal.py b/frappe/utils/goal.py new file mode 100644 index 0000000000..bf1b9c345e --- /dev/null +++ b/frappe/utils/goal.py @@ -0,0 +1,128 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def get_monthly_results(goal_doctype, goal_field, date_col, filter_str, aggregation = 'sum'): + '''Get monthly aggregation values for given field of doctype''' + + where_clause = ('where ' + filter_str) if filter_str else '' + results = frappe.db.sql(''' + select + {0}({1}) as {1}, date_format({2}, '%m-%Y') as month_year + from + `{3}` + {4} + group by + month_year'''.format(aggregation, goal_field, date_col, "tab" + + goal_doctype, where_clause), as_dict=True) + + month_to_value_dict = {} + for d in results: + month_to_value_dict[d['month_year']] = d[goal_field] + + return month_to_value_dict + +@frappe.whitelist() +def get_monthly_goal_graph_data(title, doctype, docname, goal_value_field, goal_total_field, goal_history_field, + goal_doctype, goal_doctype_link, goal_field, date_field, filter_str, aggregation="sum"): + ''' + Get month-wise graph data for a doctype based on aggregation values of a field in the goal doctype + + :param title: Graph title + :param doctype: doctype of graph doc + :param docname: of the doc to set the graph in + :param goal_value_field: goal field of doctype + :param goal_total_field: current month value field of doctype + :param goal_history_field: cached history field + :param goal_doctype: doctype the goal is based on + :param goal_doctype_link: doctype link field in goal_doctype + :param goal_field: field from which the goal is calculated + :param filter_str: where clause condition + :param aggregation: a value like 'count', 'sum', 'avg' + + :return: dict of graph data + ''' + + from frappe.utils.formatters import format_value + import json + + meta = frappe.get_meta(doctype) + doc = frappe.get_doc(doctype, docname) + + goal = doc.get(goal_value_field) + formatted_goal = format_value(goal, meta.get_field(goal_value_field), doc) + + current_month_value = doc.get(goal_total_field) + formatted_value = format_value(current_month_value, meta.get_field(goal_total_field), doc) + + from frappe.utils import today, getdate, formatdate, add_months + current_month_year = formatdate(today(), "MM-yyyy") + + history = doc.get(goal_history_field) + try: + month_to_value_dict = json.loads(history) if history and '{' in history else None + except ValueError: + month_to_value_dict = None + + if month_to_value_dict is None: + doc_filter = (goal_doctype_link + ' = "' + docname + '"') if doctype != goal_doctype else '' + if filter_str: + doc_filter += ' and ' + filter_str if doc_filter else filter_str + month_to_value_dict = get_monthly_results(goal_doctype, goal_field, date_field, doc_filter, aggregation) + frappe.db.set_value(doctype, docname, goal_history_field, json.dumps(month_to_value_dict)) + + month_to_value_dict[current_month_year] = current_month_value + + months = [] + values = [] + for i in xrange(0, 12): + month_value = formatdate(add_months(today(), -i), "MM-yyyy") + month_word = getdate(month_value).strftime('%b') + months.insert(0, month_word) + if month_value in month_to_value_dict: + values.insert(0, month_to_value_dict[month_value]) + else: + values.insert(0, 0) + + specific_values = [] + summary_values = [ + { + 'name': "This month", + 'color': 'green', + 'value': formatted_value + } + ] + + if float(goal) > 0: + specific_values = [ + { + 'name': "Goal", + 'line_type': "dashed", + 'value': goal + }, + ] + summary_values += [ + { + 'name': "Goal", + 'color': 'blue', + 'value': formatted_goal + }, + { + 'name': "Completed", + 'color': 'green', + 'value': str(int(round(float(current_month_value)/float(goal)*100))) + "%" + } + ] + + data = { + 'title': title, + # 'subtitle': + 'y_values': values, + 'x_points': months, + 'specific_values': specific_values, + 'summary_values': summary_values + } + + return data