* [sales goal] in company; graph, notifs * cleanup notifications.js, summary and specific val in graph * Add line graph, add goal data methods in goal.py * [tests] targets in notification config * [minor] type of graph as argument in parent * Update graph docs * remove company dependent test for notification * [fix] test * look for monthly history in cache * check for cached graph data in fieldversion-14
@@ -3,18 +3,21 @@ | |||||
"doctype": "Event", | "doctype": "Event", | ||||
"subject":"_Test Event 1", | "subject":"_Test Event 1", | ||||
"starts_on": "2014-01-01", | "starts_on": "2014-01-01", | ||||
"event_type": "Public" | |||||
"event_type": "Public", | |||||
"creation": "2014-01-01" | |||||
}, | }, | ||||
{ | { | ||||
"doctype": "Event", | "doctype": "Event", | ||||
"starts_on": "2014-01-01", | |||||
"subject":"_Test Event 2", | "subject":"_Test Event 2", | ||||
"event_type": "Private" | |||||
"starts_on": "2014-01-01", | |||||
"event_type": "Private", | |||||
"creation": "2014-01-01" | |||||
}, | }, | ||||
{ | { | ||||
"doctype": "Event", | "doctype": "Event", | ||||
"starts_on": "2014-01-01", | |||||
"subject": "_Test Event 3", | "subject": "_Test Event 3", | ||||
"event_type": "Private" | |||||
"starts_on": "2014-02-01", | |||||
"event_type": "Private", | |||||
"creation": "2014-02-01" | |||||
} | } | ||||
] | ] |
@@ -13,10 +13,12 @@ def get_notifications(): | |||||
return | return | ||||
config = get_notification_config() | config = get_notification_config() | ||||
groups = config.get("for_doctype").keys() + config.get("for_module").keys() | groups = config.get("for_doctype").keys() + config.get("for_module").keys() | ||||
cache = frappe.cache() | cache = frappe.cache() | ||||
notification_count = {} | notification_count = {} | ||||
notification_percent = {} | |||||
for name in groups: | for name in groups: | ||||
count = cache.hget("notification_count:" + name, frappe.session.user) | 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_doctype": get_notifications_for_doctypes(config, notification_count), | ||||
"open_count_module": get_notifications_for_modules(config, notification_count), | "open_count_module": get_notifications_for_modules(config, notification_count), | ||||
"open_count_other": get_notifications_for_other(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() | "new_messages": get_new_messages() | ||||
} | } | ||||
@@ -111,6 +114,49 @@ def get_notifications_for_doctypes(config, notification_count): | |||||
return open_count_doctype | 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): | def clear_notifications(user=None): | ||||
if frappe.flags.in_install: | if frappe.flags.in_install: | ||||
return | return | ||||
@@ -163,7 +209,7 @@ def get_notification_config(): | |||||
config = frappe._dict() | config = frappe._dict() | ||||
for notification_config in frappe.get_hooks().notification_config: | for notification_config in frappe.get_hooks().notification_config: | ||||
nc = frappe.get_attr(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.setdefault(key, {}) | ||||
config[key].update(nc.get(key, {})) | config[key].update(nc.get(key, {})) | ||||
return config | return config | ||||
@@ -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) + "%" | |||||
} | |||||
] | |||||
}); | |||||
}, | |||||
<img src="{{docs_base_url}}/assets/img/desk/line_graph.png" class="screenshot"> | |||||
Setting the mode to 'bar-graph': | |||||
<img src="{{docs_base_url}}/assets/img/desk/bar_graph.png" class="screenshot"> |
@@ -161,6 +161,7 @@ | |||||
"public/js/frappe/query_string.js", | "public/js/frappe/query_string.js", | ||||
"public/js/frappe/ui/charts.js", | "public/js/frappe/ui/charts.js", | ||||
"public/js/frappe/ui/graph.js", | |||||
"public/js/frappe/misc/rating_icons.html", | "public/js/frappe/misc/rating_icons.html", | ||||
"public/js/frappe/feedback.js" | "public/js/frappe/feedback.js" | ||||
@@ -508,6 +508,17 @@ fieldset[disabled] .form-control { | |||||
cursor: pointer; | cursor: pointer; | ||||
margin-right: 10px; | 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 */ | /* on small screens, show only icons on top */ | ||||
@media (max-width: 767px) { | @media (max-width: 767px) { | ||||
.module-view-layout .nav-stacked > li { | .module-view-layout .nav-stacked > li { | ||||
@@ -642,6 +642,92 @@ select.form-control { | |||||
box-shadow: none; | 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"] { | body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] { | ||||
height: 80px !important; | height: 80px !important; | ||||
} | } |
@@ -11,7 +11,7 @@ frappe.ui.form.Dashboard = Class.extend({ | |||||
this.progress_area = this.wrapper.find(".progress-area"); | this.progress_area = this.wrapper.find(".progress-area"); | ||||
this.heatmap_area = this.wrapper.find('.form-heatmap'); | 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 = this.wrapper.find('.form-stats'); | ||||
this.stats_area_row = this.stats_area.find('.row'); | this.stats_area_row = this.stats_area.find('.row'); | ||||
this.links_area = this.wrapper.find('.form-links'); | this.links_area = this.wrapper.find('.form-links'); | ||||
@@ -43,9 +43,9 @@ frappe.ui.form.Dashboard = Class.extend({ | |||||
this.frm.layout.show_message(); | this.frm.layout.show_message(); | ||||
}, | }, | ||||
add_comment: function(text, permanent) { | |||||
add_comment: function(text, alert_class, permanent) { | |||||
var me = this; | var me = this; | ||||
this.set_headline_alert(text); | |||||
this.set_headline_alert(text, alert_class); | |||||
if(!permanent) { | if(!permanent) { | ||||
setTimeout(function() { | setTimeout(function() { | ||||
me.clear_headline(); | me.clear_headline(); | ||||
@@ -91,6 +91,7 @@ frappe.ui.form.Dashboard = Class.extend({ | |||||
this.show(); | this.show(); | ||||
}, | }, | ||||
format_percent: function(title, percent) { | format_percent: function(title, percent) { | ||||
var width = cint(percent) < 1 ? 1 : cint(percent); | var width = cint(percent) < 1 ? 1 : cint(percent); | ||||
var progress_class = ""; | var progress_class = ""; | ||||
@@ -138,6 +139,11 @@ frappe.ui.form.Dashboard = Class.extend({ | |||||
show = true; | show = true; | ||||
} | } | ||||
if(this.data.graph) { | |||||
this.setup_graph(); | |||||
show = true; | |||||
} | |||||
if(show) { | if(show) { | ||||
this.show(); | this.show(); | ||||
} | } | ||||
@@ -383,13 +389,50 @@ frappe.ui.form.Dashboard = Class.extend({ | |||||
}, | }, | ||||
//graphs | //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) { | setup_chart: function(opts) { | ||||
var me = this; | var me = this; | ||||
this.chart_area.removeClass('hidden'); | |||||
this.graph_area.removeClass('hidden'); | |||||
$.extend(opts, { | $.extend(opts, { | ||||
wrapper: me.wrapper.find('.form-chart'), | |||||
wrapper: me.graph_area, | |||||
padding: { | padding: { | ||||
right: 30, | right: 30, | ||||
bottom: 30 | bottom: 30 | ||||
@@ -5,7 +5,7 @@ | |||||
<div id="heatmap-{{ frappe.model.scrub(frm.doctype) }}"></div> | <div id="heatmap-{{ frappe.model.scrub(frm.doctype) }}"></div> | ||||
<div class="text-muted small heatmap-message hidden"></div> | <div class="text-muted small heatmap-message hidden"></div> | ||||
</div> | </div> | ||||
<div class="form-chart form-dashboard-section hidden"></div> | |||||
<div class="form-graph form-dashboard-section hidden"></div> | |||||
<div class="form-stats form-dashboard-section hidden"> | <div class="form-stats form-dashboard-section hidden"> | ||||
<div class="row"></div> | <div class="row"></div> | ||||
</div> | </div> | ||||
@@ -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 = $('<div>') | |||||
.addClass('graph-container') | |||||
.append($(`<h6 class="title" style="margin-top: 15px;">${this.title}</h6>`)) | |||||
.append($(`<h6 class="sub-title uppercase">${this.subtitle}</h6>`)) | |||||
.append($(`<div class="graphics"></div>`)) | |||||
.append($(`<div class="stats-container"></div>`)) | |||||
.appendTo(this.parent); | |||||
let $graphics = this.container.find('.graphics'); | |||||
this.$stats_container = this.container.find('.stats-container'); | |||||
this.$graph = $('<div>') | |||||
.addClass(this.mode) | |||||
.appendTo($graphics); | |||||
this.$svg = $(`<svg class="svg" width="${this.width}" height="${this.height}"></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($(`<div class="stats"> | |||||
<span class="indicator ${d.color}">${d.name}: ${d.value}</span> | |||||
</div>`)); | |||||
}); | |||||
} | |||||
// 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)); | |||||
} | |||||
}; |
@@ -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 = '<li class="divider"></li>'; | |||||
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('<li><a class="badge-hover" data-doctype="%(data_doctype)s">\ | |||||
<span class="badge pull-right">\ | |||||
%(count)s</span> \ | |||||
%(label)s </a></li>', { | |||||
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($('<li class="divider"></li>')); | |||||
}, | |||||
// 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 | |||||
? $(`<li><a class="badge-hover" data-doctype="${name}">${label} | |||||
<span class="badge pull-right">${value}</span> | |||||
</a></li>`) | |||||
: $(`<li><a class="progress-small" data-doctype="${doc_dt}" | |||||
data-doc="${name}">${label} | |||||
<div class="progress-chart"><div class="progress"> | |||||
<div class="progress-bar" style="width: ${value}%"></div> | |||||
</div></div> | |||||
</a></li>`); | |||||
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 { | } 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); | |||||
} | |||||
}, | |||||
} |
@@ -335,7 +335,7 @@ _f.Frm.prototype.refresh_header = function(is_a_different_doc) { | |||||
! this.is_dirty() && | ! this.is_dirty() && | ||||
! this.is_new() && | ! this.is_new() && | ||||
this.doc.docstatus===0) { | 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(); | this.clear_custom_buttons(); | ||||
@@ -66,8 +66,8 @@ a[disabled="disabled"] { | |||||
#alert-container .desk-alert { | #alert-container .desk-alert { | ||||
-webkit-box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); | -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; | padding: 10px 40px 10px 20px; | ||||
max-width: 400px; | max-width: 400px; | ||||
@@ -318,19 +318,35 @@ textarea.form-control { | |||||
} | } | ||||
.open-notification { | .open-notification { | ||||
position:relative; | |||||
position:relative; | |||||
left: 2px; | 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; | cursor: pointer; | ||||
margin-right: 10px; | 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 */ | /* on small screens, show only icons on top */ | ||||
@media (max-width: 767px) { | @media (max-width: 767px) { | ||||
.module-view-layout .nav-stacked > li { | .module-view-layout .nav-stacked > li { | ||||
@@ -825,7 +841,7 @@ textarea.form-control { | |||||
} | } | ||||
.c3-line { | .c3-line { | ||||
stroke-width: 3px; | |||||
stroke-width: 3px; | |||||
} | } | ||||
.c3-tooltip { | .c3-tooltip { | ||||
@@ -897,10 +913,10 @@ input[type="checkbox"] { | |||||
// Will not be required after commonifying lists with empty state | // Will not be required after commonifying lists with empty state | ||||
.multiselect-empty-state{ | .multiselect-empty-state{ | ||||
min-height: 300px; | 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 | // mozilla doesn't support | ||||
@@ -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"] { | body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] { | ||||
height: 80px !important; | height: 80px !important; | ||||
} | } | ||||
@@ -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) |
@@ -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 |