diff --git a/cor_custom/models/project.py b/cor_custom/models/project.py
index c9d707c..60bcfbe 100755
--- a/cor_custom/models/project.py
+++ b/cor_custom/models/project.py
@@ -71,7 +71,7 @@ class Project(models.Model):
comment = fields.Text(string='Comment')
tag_ids = fields.Many2many('custom.project.tags', string='Tags')
- @api.onchange('allowed_internal_user_ids')
+ """@api.onchange('allowed_internal_user_ids')
def onchange_add_allowed_internal_users(self):
user_list = []
consultant_list = []
@@ -89,7 +89,7 @@ class Project(models.Model):
if employee_obj:
for employee in employee_obj:
self.sale_line_employee_ids.create({'project_id': self._origin.id,
- 'employee_id': employee})
+ 'employee_id': employee})"""
def _onchange_calculate_timesheet_hours(self):
diff --git a/cor_custom/report/project_hours_report.py b/cor_custom/report/project_hours_report.py
new file mode 100755
index 0000000..0620365
--- /dev/null
+++ b/cor_custom/report/project_hours_report.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, tools
+
+
+class BudgetHrsAnalysis(models.Model):
+
+ _name = "project.consultant.hrs.report"
+ _description = "Project consultant hours analysis report"
+ #_order = 'project_id desc, hours_type asc, end_date desc, employee_id'
+ _order = 'project_id desc, 1, 2, hours_type, employee_id, end_date, start_date'
+ _auto = False
+
+ project_id = fields.Many2one('project.project', string='Project', readonly=True)
+ employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True)
+ start_date = fields.Date(string='Start Date', readonly=True)
+ end_date = fields.Date(string='End Date', readonly=True)
+ hours_type = fields.Char(string="Hours Type", readonly=True)
+ hours = fields.Float("Hours", digits=(16, 2), readonly=True, group_operator="sum")
+ percentage = fields.Float("Percentage (%)")
+ #roworder = fields.Integer("Row Order")
+ #ptime = fields.Char(string="Time", readonly=True)
+
+ """def _generate_order_by(self, order_spec, query):
+ order_by = super(BudgetHrsAnalysis, self)._generate_order_by(order_spec, query)
+ print("order by>>>>>>>>>>>>>>", order_by)
+ return order_by"""
+
+ def init(self):
+ '''Create the view'''
+ tools.drop_view_if_exists(self._cr, self._table)
+ self._cr.execute("""
+ CREATE OR REPLACE VIEW %s AS (
+ SELECT ROW_NUMBER() OVER() as id, project_id, start_date, end_date, employee_id, hours_type, hours, percentage
+ from (
+ select project_id as project_id, start_date as start_date, end_date as end_date,
+ employee_id as employee_id, 'Budgeted Hours for period' as hours_type, budgeted_hours as hours,
+ percentage as percentage
+ from project_consultant_hrs ph
+ union
+ select project_id as project_id, start_date as start_date, end_date as end_date,
+ employee_id as employee_id, 'Actual Hours for period' as hours_type, actual_hours as hours,
+ actual_percentage as percentage
+ from project_consultant_hrs ph
+ ) as res order by
+ project_id desc,
+ hours_type,
+ employee_id, end_date, start_date
+ )""" % (self._table,))
+
+
diff --git a/cor_custom/report/project_hours_report_view.xml b/cor_custom/report/project_hours_report_view.xml
new file mode 100755
index 0000000..07026d7
--- /dev/null
+++ b/cor_custom/report/project_hours_report_view.xml
@@ -0,0 +1,97 @@
+
+
+
+
+ project.consultant.hrs.report.form
+ project.consultant.hrs.report
+
+
+
+
+
+
+ project.consultant.hrs.report.tree
+ project.consultant.hrs.report
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ project.consultant.hrs.report.search
+ project.consultant.hrs.report
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ project.consultant.hrs.report.graph
+ project.consultant.hrs.report
+
+
+
+
+
+
+
+
+
+ Consultant Allocation
+ ir.actions.act_window
+ project.consultant.hrs.report
+ tree,graph
+
+ {
+ 'search_default_project': 1,
+ 'search_default_consultant': 1,
+ }
+
+
+
+
+
+ Project Consul Report Hours
+ project.project
+
+
+
+
+
+
+
+
+
diff --git a/project_report/__manifest__.py b/project_report/__manifest__.py
index 785de93..dccee08 100755
--- a/project_report/__manifest__.py
+++ b/project_report/__manifest__.py
@@ -20,6 +20,9 @@
'report/project_budget_hrs_analysis_views.xml',
'report/project_budget_amt_analysis_views.xml',
],
+ 'qweb': [
+ "static/src/xml/base.xml",
+ ],
'auto_install': False,
'installable': True,
}
\ No newline at end of file
diff --git a/project_report/static/src/js/graph_renderer.js b/project_report/static/src/js/graph_renderer.js
index 6708e77..ff2506f 100755
--- a/project_report/static/src/js/graph_renderer.js
+++ b/project_report/static/src/js/graph_renderer.js
@@ -1,10 +1,29 @@
odoo.define("project_report.GraphRenderer", function(require) {
var GraphRenderer = require("web.GraphRenderer");
-var config = require("web.config");
+var AbstractRenderer = require('web.AbstractRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var dataComparisonUtils = require('web.dataComparisonUtils');
var fieldUtils = require('web.field_utils');
-// Hide top legend when too many items for device size
-var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class);
+var _t = core._t;
+var DateClasses = dataComparisonUtils.DateClasses;
+var qweb = core.qweb;
+
+var CHART_TYPES = ['pie', 'bar', 'horizontalBar', 'line'];
+
+var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#d62728",
+ "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2",
+ "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"];
+var COLOR_NB = COLORS.length;
+
+function hexToRGBA(hex, opacity) {
+ var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ var rgb = result.slice(1, 4).map(function (n) {
+ return parseInt(n, 16);
+ }).join(',');
+ return 'rgba(' + rgb + ',' + opacity + ')';
+}
// used to format values in tooltips and yAxes.
var FORMAT_OPTIONS = {
@@ -21,6 +40,13 @@ var FORMAT_OPTIONS = {
},
};
+var NO_DATA = [_t('No data')];
+NO_DATA.isNoData = true;
+
+// Hide top legend when too many items for device size
+var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class);
+
+
GraphRenderer.include({
init: function(parent, state, params) {
this._super.apply(this, arguments);
@@ -32,13 +58,333 @@ var FORMAT_OPTIONS = {
},
_formatValue: function (value) {
- //this._super.apply(this, arguments);
+ //this._super.apply(this, arguments);
var measureField = this.fields[this.state.measure];
var formatter = fieldUtils.format.float;
var formatedValue = formatter(value, measureField, FORMAT_OPTIONS);
return formatedValue;
},
+ /**
+ * Determines the initial section of the labels array
+ * over a dataset has to be completed. The section only depends
+ * on the datasets origins.
+ *
+ * @private
+ * @param {number} originIndex
+ * @param {number} defaultLength
+ * @returns {number}
+ */
+ _getDatasetDataLength: function (originIndex, defaultLength) {
+ if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) {
+ return this.dateClasses.dateSets[originIndex].length;
+ }
+ return defaultLength;
+ },
+
+ /**
+ * Determines over which label is the data point
+ *
+ * @private
+ * @param {Object} dataPt
+ * @returns {Array}
+ */
+ _getLabel: function (dataPt) {
+ var i = this.state.comparisonFieldIndex;
+ if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) {
+ if (i === 0) {
+ return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])];
+ } else {
+ return dataPt.labels.slice(0, 1);
+ }
+ } else if (i === 0) {
+ return Array.prototype.concat.apply([], [
+ this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]),
+ dataPt.labels.slice(i+1)
+ ]);
+ } else {
+ return dataPt.labels;
+ }
+ },
+
+ /**
+ * Returns an object used to style chart elements independently from the datasets.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getElementOptions: function () {
+ var elementOptions = {};
+ if (this.state.mode === 'bar' || this.state.mode === 'horizontalBar') {
+ elementOptions.rectangle = {borderWidth: 1};
+ } else if (this.state.mode === 'line') {
+ elementOptions.line = {
+ tension: 0,
+ fill: false,
+ };
+ }
+ return elementOptions;
+ },
+
+ /**
+ * Determines to which dataset belong the data point
+ *
+ * @private
+ * @param {Object} dataPt
+ * @returns {string}
+ */
+ _getDatasetLabel: function (dataPt) {
+ if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) {
+ // ([origin] + second to last groupBys) or measure
+ var datasetLabel = dataPt.labels.slice(1).join("/");
+ if (this.state.origins.length > 1) {
+ datasetLabel = this.state.origins[dataPt.originIndex] +
+ (datasetLabel ? ('/' + datasetLabel) : '');
+ }
+ datasetLabel = datasetLabel || this.fields[this.state.measure].string;
+ return datasetLabel;
+ }
+ return this.state.origins[dataPt.originIndex];
+ },
+
+
+ _filterDataPoints: function () {
+ var dataPoints = [];
+ if (_.contains(['bar', 'horizontalBar', 'pie'], this.state.mode)) {
+ dataPoints = this.state.dataPoints.filter(function (dataPt) {
+ return dataPt.count > 0;
+ });
+ } else if (this.state.mode === 'line') {
+ var counts = 0;
+ this.state.dataPoints.forEach(function (dataPt) {
+ if (dataPt.labels[0] !== _t("Undefined")) {
+ dataPoints.push(dataPt);
+ }
+ counts += dataPt.count;
+ });
+ // data points with zero count might have been created on purpose
+ // we only remove them if there are no data point with positive count
+ if (counts === 0) {
+ dataPoints = [];
+ }
+ }
+ return dataPoints;
+ },
+
+
+ /**
+ * Returns the options used to generate the chart legend.
+ *
+ * @private
+ * @param {Number} datasetsCount
+ * @returns {Object}
+ */
+ _getLegendOptions: function (datasetsCount) {
+ var legendOptions = {
+ display: datasetsCount <= MAX_LEGEND_LENGTH,
+ // position: this.state.mode === 'pie' ? 'right' : 'top',
+ position: 'top',
+ onHover: this._onlegendTooltipHover.bind(this),
+ onLeave: this._onLegendTootipLeave.bind(this),
+ };
+ var self = this;
+ if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) {
+ var referenceColor;
+ if (this.state.mode === 'bar') {
+ referenceColor = 'backgroundColor';
+ } else {
+ referenceColor = 'borderColor';
+ }
+ legendOptions.labels = {
+ generateLabels: function (chart) {
+ var data = chart.data;
+ return data.datasets.map(function (dataset, i) {
+ return {
+ text: self._shortenLabel(dataset.label),
+ fullText: dataset.label,
+ fillStyle: dataset[referenceColor],
+ hidden: !chart.isDatasetVisible(i),
+ lineCap: dataset.borderCapStyle,
+ lineDash: dataset.borderDash,
+ lineDashOffset: dataset.borderDashOffset,
+ lineJoin: dataset.borderJoinStyle,
+ lineWidth: dataset.borderWidth,
+ strokeStyle: dataset[referenceColor],
+ pointStyle: dataset.pointStyle,
+ datasetIndex: i,
+ };
+ });
+ },
+ };
+ } else {
+ legendOptions.labels = {
+ generateLabels: function (chart) {
+ var data = chart.data;
+ var metaData = data.datasets.map(function (dataset, index) {
+ return chart.getDatasetMeta(index).data;
+ });
+ return data.labels.map(function (label, i) {
+ var hidden = metaData.reduce(
+ function (hidden, data) {
+ if (data[i]) {
+ hidden = hidden || data[i].hidden;
+ }
+ return hidden;
+ },
+ false
+ );
+ var fullText = self._relabelling(label);
+ var text = self._shortenLabel(fullText);
+ return {
+ text: text,
+ fullText: fullText,
+ fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i),
+ hidden: hidden,
+ index: i,
+ };
+ });
+ },
+ };
+ }
+ return legendOptions;
+ },
+
+ _isRedirectionEnabled: function () {
+ return !this.disableLinking &&
+ (this.state.mode === 'bar' || this.state.mode === 'horizontalBar' || this.state.mode === 'pie');
+ },
+
+ /**
+ * Determine how to relabel a label according to a given origin.
+ * The idea is that the getLabel function is in general not invertible but
+ * it is when restricted to the set of dataPoints coming from a same origin.
+
+ * @private
+ * @param {Array} label
+ * @param {Array} originIndex
+ * @returns {string}
+ */
+ _relabelling: function (label, originIndex) {
+ if (label.isNoData) {
+ return label[0];
+ }
+ var i = this.state.comparisonFieldIndex;
+ if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && i === 0) {
+ // here label is an array of length 1 and contains a number
+ return this.dateClasses.representative(label, originIndex) || '';
+ } else if (this.state.mode === 'pie' && i === 0) {
+ // here label is an array of length at least one containing string or numbers
+ var labelCopy = label.slice(0);
+ if (originIndex !== undefined) {
+ labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex));
+ } else {
+ labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i]));
+ }
+ return labelCopy.join('/');
+ }
+ // here label is an array containing strings or numbers.
+ return label.join('/') || _t('Total');
+ },
+
+ async _renderView() {
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ this.$el.empty();
+ if (!_.contains(CHART_TYPES, this.state.mode)) {
+ this.trigger_up('warning', {
+ title: _t('Invalid mode for chart'),
+ message: _t('Cannot render chart with mode : ') + this.state.mode
+ });
+ }
+ var dataPoints = this._filterDataPoints();
+ dataPoints = this._sortDataPoints(dataPoints);
+ if (this.isInDOM) {
+ this._renderTitle();
+
+ // detect if some pathologies are still present after the filtering
+ if (this.state.mode === 'pie') {
+ const someNegative = dataPoints.some(dataPt => dataPt.value < 0);
+ const somePositive = dataPoints.some(dataPt => dataPt.value > 0);
+ if (someNegative && somePositive) {
+ const context = {
+ title: _t("Invalid data"),
+ description: [
+ _t("Pie chart cannot mix positive and negative numbers. "),
+ _t("Try to change your domain to only display positive results")
+ ].join("")
+ };
+ this._renderNoContentHelper(context);
+ return;
+ }
+ }
+
+ if (this.state.isSample && !this.isEmbedded) {
+ this._renderNoContentHelper();
+ }
+
+ // only render the graph if the widget is already in the DOM (this
+ // happens typically after an update), otherwise, it will be
+ // rendered when the widget will be attached to the DOM (see
+ // 'on_attach_callback')
+ var $canvasContainer = $('
', {class: 'o_graph_canvas_container'});
+ var $canvas = $('').attr('id', this.chartId);
+ $canvasContainer.append($canvas);
+ this.$el.append($canvasContainer);
+
+ var i = this.state.comparisonFieldIndex;
+ if (i === 0) {
+ this.dateClasses = this._getDateClasses(dataPoints);
+ }
+ if (this.state.mode === 'bar') {
+ this._renderBarChart(dataPoints);
+ } else if (this.state.mode === 'horizontalBar') {
+ this._renderhorizontalBarChart(dataPoints);
+ }
+ else if (this.state.mode === 'line') {
+ this._renderLineChart(dataPoints);
+ } else if (this.state.mode === 'pie') {
+ this._renderPieChart(dataPoints);
+ }
+ }
+ },
+
+ _getScaleHBarOptions: function () {
+ var self = this;
+ if (_.contains(['horizontalBar'], this.state.mode)) {
+ return {
+ xAxes: [{
+ type: 'linear',
+ scaleLabel: {
+ display: !this.isEmbedded,
+ labelString: this.fields[this.state.measure].string,
+ },
+ ticks: {
+ callback: this._formatValue.bind(this),
+ suggestedMax: 0,
+ suggestedMin: 0,
+ }
+ }],
+ yAxes: [{
+ type: 'category',
+ scaleLabel: {
+ display: this.state.processedGroupBy.length && !this.isEmbedded,
+ labelString: this.state.processedGroupBy.length ?
+ this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '',
+ },
+ ticks: {
+ // don't use bind: callback is called with 'index' as second parameter
+ // with value labels.indexOf(label)!
+ callback: function (label) {
+ return self._relabelling(label);
+ },
+ },
+ }],
+ };
+ }
+ return {};
+ },
+
_animationOptions: function () {
var animationOptions = {};
var GraphVal = this;
@@ -108,5 +454,79 @@ var FORMAT_OPTIONS = {
});
},
+ _prepare_HBar_Options: function (datasetsCount) {
+ const options = {
+ maintainAspectRatio: false,
+ scales: this._getScaleHBarOptions(),
+ legend: this._getLegendOptions(datasetsCount),
+ tooltips: this._getTooltipOptions(),
+ elements: this._getElementOptions(),
+ //animation: this._animationOptions(),
+ };
+ if (this._isRedirectionEnabled()) {
+ options.onClick = this._onGraphClicked.bind(this);
+ }
+ return options;
+ },
+
+
+ _renderhorizontalBarChart: function (dataPoints) {
+ var self = this;
+
+ // prepare data
+ var data = this._prepareData(dataPoints);
+
+ data.datasets.forEach(function (dataset, index) {
+ // used when stacked
+ dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
+ // set dataset color
+ var color = self._getColor(index);
+ dataset.backgroundColor = color;
+ });
+
+ // prepare options
+ var options = this._prepare_HBar_Options(data.datasets.length);
+ // create chart
+ var ctx = document.getElementById(this.chartId);
+ this.chart = new Chart(ctx, {
+ type: 'horizontalBar',
+ data: data,
+ options: options,
+ });
+ },
+ /**
+ * Sort datapoints according to the current order (ASC or DESC).
+ *
+ * Note: this should be moved to the model at some point.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ * @returns {Object[]} sorted dataPoints if orderby set on state
+ */
+ _sortDataPoints(dataPoints) {
+ if (!Object.keys(this.state.timeRanges).length && this.state.orderBy &&
+ ['bar', 'horizontalBar', 'line'].includes(this.state.mode) && this.state.groupBy.length) {
+ // group data by their x-axis value, and then sort datapoints
+ // based on the sum of values by group in ascending/descending order
+ const groupByFieldName = this.state.groupBy[0].split(':')[0];
+ const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one';
+ const groupedDataPoints = {};
+ dataPoints.forEach(function (dataPoint) {
+ const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0];
+ groupedDataPoints[key] = groupedDataPoints[key] || [];
+ groupedDataPoints[key].push(dataPoint);
+ });
+ dataPoints = _.sortBy(groupedDataPoints, function (group) {
+ return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0);
+ });
+ dataPoints = dataPoints.flat();
+ if (this.state.orderBy === 'desc') {
+ dataPoints = dataPoints.reverse('value');
+ }
+ }
+ return dataPoints;
+ },
+
+
});
});
diff --git a/project_report/views/assets.xml b/project_report/views/assets.xml
index ac5abf2..6041fa0 100755
--- a/project_report/views/assets.xml
+++ b/project_report/views/assets.xml
@@ -3,6 +3,7 @@
+