", {
+ "class": "ganttview-block",
+ "title": series.name + ", " + size + " days \n"+ series.start +" to "+ series.end,
+ "css": {
+ "width": ((size * cellWidth) - 7) + "px",
+ "margin-left": ((offset * cellWidth) + 3) + "px"
+ }
+ });
+ addBlockData(block, data[i], series);
+ if (data[i].series[j].color) {
+ block.css("background-color", data[i].series[j].color);
+ }
+ block.append(jQuery("
", { "class": "ganttview-block-text" }).text(size));
+ jQuery(rows[rowIdx]).append(block);
+ rowIdx = rowIdx + 1;
+ }
+ }
+ }
+
+ function addBlockData(block, data, series) {
+ // This allows custom attributes to be added to the series data objects
+ // and makes them available to the 'data' argument of click, resize, and drag handlers
+ var blockData = { id: data.id, name: data.name };
+ jQuery.extend(blockData, series);
+ block.data("block-data", blockData);
+ }
+
+ function applyLastClass(div) {
+ jQuery("div.ganttview-grid-row div.ganttview-grid-row-cell:last-child", div).addClass("last");
+ jQuery("div.ganttview-hzheader-days div.ganttview-hzheader-day:last-child", div).addClass("last");
+ jQuery("div.ganttview-hzheader-months div.ganttview-hzheader-month:last-child", div).addClass("last");
+ }
+
+ return {
+ render: render
+ };
+ }
+
+ var Behavior = function (div, opts) {
+
+ function apply() {
+
+ if (opts.behavior.clickable) {
+ bindBlockClick(div, opts.behavior.onDblClick);
+ }
+
+ if (opts.behavior.resizable) {
+ bindBlockResize(div, opts.cellWidth, opts.start, opts.behavior.onResize);
+ }
+
+ if (opts.behavior.draggable) {
+ bindBlockDrag(div, opts.cellWidth, opts.start, opts.behavior.onDrag);
+ }
+ }
+
+ function bindBlockClick(div, callback) {
+ jQuery("div.ganttview-block", div).on("dblclick", function () {
+ if (callback) { callback(jQuery(this).data("block-data")); }
+ });
+ }
+
+ function bindBlockResize(div, cellWidth, startDate, callback) {
+ jQuery("div.ganttview-block", div).resizable({
+ grid: cellWidth,
+ handles: "e,w",
+ stop: function () {
+ var block = jQuery(this);
+ updateDataAndPosition(div, block, cellWidth, startDate);
+ if (callback) { callback(block.data("block-data")); }
+ }
+ });
+ }
+
+ function bindBlockDrag(div, cellWidth, startDate, callback) {
+ jQuery("div.ganttview-block", div).draggable({
+ axis: "x",
+ grid: [cellWidth, cellWidth],
+ stop: function () {
+ var block = jQuery(this);
+ updateDataAndPosition(div, block, cellWidth, startDate);
+ if (callback) { callback(block.data("block-data")); }
+ }
+ });
+ }
+
+ function updateDataAndPosition(div, block, cellWidth, startDate) {
+ var container = jQuery("div.ganttview-slide-container", div);
+ var scroll = container.scrollLeft();
+ var offset = block.offset().left - container.offset().left - 1 + scroll;
+
+ // Set new start date
+ var daysFromStart = Math.round(offset / cellWidth);
+ var newStart = startDate.clone().addDays(daysFromStart);
+ block.data("block-data").start = newStart;
+
+ // Set new end date
+ var width = block.outerWidth();
+ var numberOfDays = Math.round(width / cellWidth) - 1;
+ block.data("block-data").end = newStart.clone().addDays(numberOfDays);
+ jQuery("div.ganttview-block-text", block).text(numberOfDays + 1);
+
+ // Remove top and left properties to avoid incorrect block positioning,
+ // set position to relative to keep blocks relative to scrollbar when scrolling
+ block.css("top", "").css("left", "")
+ .css("position", "relative").css("margin-left", offset + "px");
+ }
+
+ return {
+ apply: apply
+ };
+ }
+
+ var ArrayUtils = {
+
+ contains: function (arr, obj) {
+ var has = false;
+ for (var i = 0; i < arr.length; i++) { if (arr[i] == obj) { has = true; } }
+ return has;
+ }
+ };
+
+ var DateUtils = {
+
+ daysBetween: function (start, end) {
+ if (!start || !end) { return 0; }
+ start = Date.parse(start); end = Date.parse(end);
+ if (start.getYear() == 1901 || end.getYear() == 8099) { return 0; }
+ var count = 0, date = start.clone();
+ while (date.compareTo(end) == -1) { count = count + 1; date.addDays(1); }
+ return count;
+ },
+
+ isWeekend: function (date) {
+ return date.getDay() % 6 == 0;
+ },
+
+ isToday: function (date) {
+ return date.isToday();
+ },
+
+ getBoundaryDatesFromData: function (data, minDays) {
+ var minStart = new Date(); maxEnd = new Date();
+ for (var i = 0; i < data.length; i++) {
+ for (var j = 0; j < data[i].series.length; j++) {
+ var start = Date.parse(data[i].series[j].start);
+ var end = Date.parse(data[i].series[j].end)
+ if (i == 0 && j == 0) { minStart = start; maxEnd = end; }
+ if (minStart.compareTo(start) == 1) { minStart = start; }
+ if (maxEnd.compareTo(end) == -1) { maxEnd = end; }
+ }
+ }
+
+ // Insure that the width of the chart is at least the slide width to avoid empty
+ // whitespace to the right of the grid
+ if (DateUtils.daysBetween(minStart, maxEnd) < minDays) {
+ maxEnd = minStart.clone().addDays(minDays);
+ }
+
+ return [minStart, maxEnd];
+ }
+ };
+
+})(jQuery);
diff --git a/addons/web/static/src/img/favicon.ico b/addons/web/static/src/img/favicon.ico
index 4abb4092..ec39d4dd 100644
Binary files a/addons/web/static/src/img/favicon.ico and b/addons/web/static/src/img/favicon.ico differ
diff --git a/addons/web/static/src/img/logo.png b/addons/web/static/src/img/logo.png
index eeb685b9..3472b13d 100644
Binary files a/addons/web/static/src/img/logo.png and b/addons/web/static/src/img/logo.png differ
diff --git a/addons/web/static/src/img/logo2.png b/addons/web/static/src/img/logo2.png
index dc9ab9a3..63cf6d4d 100644
Binary files a/addons/web/static/src/img/logo2.png and b/addons/web/static/src/img/logo2.png differ
diff --git a/addons/web/static/src/img/logo_inverse_white_206px.png b/addons/web/static/src/img/logo_inverse_white_206px.png
index 528d485f..418cc4c0 100644
Binary files a/addons/web/static/src/img/logo_inverse_white_206px.png and b/addons/web/static/src/img/logo_inverse_white_206px.png differ
diff --git a/addons/web/static/src/img/nologo.png b/addons/web/static/src/img/nologo.png
index 9f8cf01d..fbbe25fd 100644
Binary files a/addons/web/static/src/img/nologo.png and b/addons/web/static/src/img/nologo.png differ
diff --git a/addons/web/static/src/img/view_empty_arrow.png b/addons/web/static/src/img/view_empty_arrow.png
index dc2b88a3..e7a33a44 100644
Binary files a/addons/web/static/src/img/view_empty_arrow.png and b/addons/web/static/src/img/view_empty_arrow.png differ
diff --git a/addons/web/static/src/js/views/gantt/gantt_controller.js b/addons/web/static/src/js/views/gantt/gantt_controller.js
new file mode 100644
index 00000000..2c23fae1
--- /dev/null
+++ b/addons/web/static/src/js/views/gantt/gantt_controller.js
@@ -0,0 +1,25 @@
+flectra.define('web.GanttController', function (require) {
+"use strict";
+/*---------------------------------------------------------
+ * Flectra Gantt view
+ *---------------------------------------------------------*/
+
+var AbstractController = require('web.AbstractController');
+
+return AbstractController.extend({
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, {
+ updateRecord: '_onUpdateRecord',
+ }),
+ _onUpdateRecord: function (record) {
+ this._rpc({
+ model: this.model.modelName,
+ method: 'write',
+ args: [record.data.id, {
+ [this.model.data.arch['date_start']]: record.data.start.toString('yyyy-M-d'),
+ [this.model.data.arch['date_stop']]: record.data.end.toString('yyyy-M-d'),
+ }],
+ }).then(this.reload.bind(this));
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/gantt/gantt_model.js b/addons/web/static/src/js/views/gantt/gantt_model.js
new file mode 100644
index 00000000..659ab401
--- /dev/null
+++ b/addons/web/static/src/js/views/gantt/gantt_model.js
@@ -0,0 +1,144 @@
+flectra.define('web.GanttModel', function (require) {
+"use strict";
+
+/**
+ * The gantt model is responsible for fetching and processing data from the
+ * server. It basically just do a search_read and format/normalize data.
+ */
+
+var AbstractModel = require('web.AbstractModel');
+
+return AbstractModel.extend({
+ /**
+ * @override
+ * @param {Object} params
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.data = null;
+ },
+ /**
+ * @override
+ * @param {Object} params
+ * @param {string[]} params.groupedBy a list of valid field names
+ * @param {Object} params.context
+ * @param {string[]} params.domain
+ * @returns {Deferred}
+ */
+ load: function (params) {
+ this.modelName = params.modelName;
+ this.data = {
+ records: [],
+ domain: params.domain,
+ context: params.context,
+ groupedBy: params.groupedBy || [],
+ arch: params.arch.attrs,
+ };
+ return this._loadData();
+ },
+ /**
+ * @override
+ * @param {Object} params
+ * @param {string[]} params.groupedBy a list of valid field names
+ * @param {Object} params.context
+ * @param {string[]} params.domain
+ * @returns {Deferred}
+ */
+ reload: function (handle, params) {
+ if (params.domain) {
+ this.data.domain = params.domain;
+ }
+ if (params.context) {
+ this.data.context = params.context;
+ }
+ if (params.groupBy) {
+ this.data.groupedBy = params.groupBy;
+ }
+ return this._loadData();
+ },
+ /**
+ * @returns {Deferred}
+ */
+ _loadData: function () {
+ var self = this;
+ return this._rpc({
+ model: this.modelName,
+ method: 'search_read',
+ context: this.data.context,
+ domain: this.data.domain,
+ })
+ .then(function (records) {
+ self.data.records = self._processData(records);
+ });
+ },
+ _processData: function (raw_datas) {
+ /**
+ * GroupBy is only supported till 1st level !
+ * @todo Flectra: Support Multi level GroupBy
+ */
+ var self = this;
+ var ganttData = [];
+ if (self.data.groupedBy.length) {
+ _.each(raw_datas, function (raw_data) {
+ var grpByStr = raw_data[self.data.groupedBy[0]] ? raw_data[self.data.groupedBy[0]] : 'Undefined';
+ if (grpByStr && grpByStr instanceof Array) {
+ grpByStr = raw_data[self.data.groupedBy[0]] ? raw_data[self.data.groupedBy[0]][1] : 'Undefined';
+ }
+ var keyCheck = _.findKey(ganttData, {name: grpByStr});
+ if (!keyCheck) {
+ ganttData.push({
+ name: grpByStr,
+ series: [],
+ });
+ }
+ keyCheck = _.findKey(ganttData, {name: grpByStr});
+ if (ganttData[keyCheck]) {
+ if (raw_data[self.data.arch['date_stop']]) {
+ ganttData[keyCheck].series.push({
+ id: raw_data['id'],
+ name: raw_data['display_name'],
+ start: raw_data[self.data.arch['date_start']].split(' ')[0],
+ end: raw_data[self.data.arch['date_stop']].split(' ')[0]
+ });
+ } else {
+ ganttData[keyCheck].series.push({
+ id: raw_data['id'],
+ name: raw_data['display_name'],
+ start: raw_data[self.data.arch['date_start']].split(' ')[0],
+ end: raw_data[self.data.arch['date_start']].split(' ')[0]
+ });
+ }
+ }
+ });
+ } else {
+ _.each(raw_datas, function (raw_data) {
+ if (raw_data[self.data.arch['date_stop']]) {
+ ganttData.push({
+ series: [
+ {
+ id: raw_data['id'],
+ name: raw_data['display_name'],
+ start: raw_data[self.data.arch['date_start']].split(' ')[0],
+ end: raw_data[self.data.arch['date_stop']].split(' ')[0]
+ },
+ ],
+ });
+ } else {
+ ganttData.push({
+ series: [
+ {
+ id: raw_data['id'],
+ name: raw_data['display_name'],
+ start: raw_data[self.data.arch['date_start']].split(' ')[0],
+ end: raw_data[self.data.arch['date_start']].split(' ')[0]
+ },
+ ],
+ });
+ }
+ });
+ }
+ return ganttData;
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/gantt/gantt_renderer.js b/addons/web/static/src/js/views/gantt/gantt_renderer.js
new file mode 100644
index 00000000..f84f5b3f
--- /dev/null
+++ b/addons/web/static/src/js/views/gantt/gantt_renderer.js
@@ -0,0 +1,122 @@
+flectra.define('web.GanttRenderer', function (require) {
+"use strict";
+
+/**
+ * The graph renderer turns the data from the graph model into a nice looking
+ * svg chart. This code uses the nvd3 library.
+ *
+ * Note that we use a custom build for the nvd3, with only the model we actually
+ * use.
+ */
+
+var core = require('web.core');
+var AbstractRenderer = require('web.AbstractRenderer');
+var Dialog = require('web.Dialog');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+return AbstractRenderer.extend({
+ template: "GanttView",
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} state
+ * @param {Object} params
+ * @param {boolean} params.stacked
+ */
+ init: function (parent, state, params) {
+ this.parent = parent;
+ this._super.apply(this, arguments);
+ },
+
+ /**
+ * Render the chart.
+ *
+ * Note that This method is synchronous, but the actual rendering is done
+ * asynchronously (in a setTimeout). The reason for that is that nvd3/d3
+ * needs to be in the DOM to correctly render itself. So, we trick Flectra by
+ * returning immediately, then wait a tiny interval before actually
+ * displaying the data.
+ *
+ * @returns {Deferred} The _super deferred is actually resolved immediately
+ */
+ _render: function () {
+ this.data = this.parent.active_view.controller.model.data;
+ this._loadGanttView();
+ return $.when();
+ },
+ _loadGanttView: function () {
+ var self = this;
+ this.$el.empty().ganttView({
+ data: self.data.records,
+ slideWidth: 'auto',
+ cellWidth: 20,
+ behavior: {
+ onDblClick: function (data) {
+ var dialog = new Dialog(self, {
+ title: _t(data.name),
+ $content: $(QWeb.render('GanttViewWizard')),
+ size: 'small',
+ buttons: [
+ {text: _t("Save"), classes: 'btn-success', click: _.bind(_callSave, self)},
+ {text: _t("Cancel"), classes: 'btn-danger', close: true}
+ ]
+ }).open();
+
+ dialog.opened().then(function () {
+ var datepickers_options = {
+ keepOpen: true,
+ minDate: moment({y: 1900}),
+ maxDate: moment().add(200, "y"),
+ calendarWeeks: true,
+ icons: {
+ time: 'fa fa-clock-o',
+ date: 'fa fa-calendar',
+ next: 'fa fa-chevron-right',
+ previous: 'fa fa-chevron-left',
+ up: 'fa fa-chevron-up',
+ down: 'fa fa-chevron-down',
+ },
+ locale: moment.locale(),
+ format: "YYYY-MM-DD",
+ ignoreReadonly: true
+ };
+ dialog.$el.find('input#start_date').val(data.start);
+ dialog.$el.find('input#end_date').val(data.end);
+ dialog.$el.find('input#start_date, input#end_date').datetimepicker(datepickers_options);
+ });
+
+ function _callSave(event) {
+ var newData = {
+ start: dialog.$el.find('input#start_date').val().toString(),
+ end: dialog.$el.find('input#end_date').val().toString(),
+ id: data.id
+ };
+ if (data.start !== newData.start || data.end !== newData.end) {
+ var start_data = new Date(dialog.$el.find('input#start_date').val().toString()).getTime();
+ var end_data = new Date(dialog.$el.find('input#end_date').val().toString()).getTime();
+ if(start_data <= end_data){
+ self.trigger_up('updateRecord', newData);
+ dialog.close();
+ }else {
+ self.do_warn(_t("Warning"), _t("Start date should be less than or equal to End date"));
+ }
+ }
+ }
+ },
+
+ onResize: function (data) {
+ self.trigger_up('updateRecord', data);
+ },
+
+ onDrag: function (data) {
+ self.trigger_up('updateRecord', data);
+ },
+ }
+ });
+ this.$el.removeAttr('style');
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/gantt/gantt_view.js b/addons/web/static/src/js/views/gantt/gantt_view.js
new file mode 100644
index 00000000..3d9fe207
--- /dev/null
+++ b/addons/web/static/src/js/views/gantt/gantt_view.js
@@ -0,0 +1,32 @@
+flectra.define('web.GanttView', function (require) {
+"use strict";
+
+var AbstractView = require('web.AbstractView');
+var core = require('web.core');
+var GanttModel = require('web.GanttModel');
+var GanttRenderer = require('web.GanttRenderer');
+var Controller = require('web.GanttController');
+
+var _lt = core._lt;
+
+var GanttView = AbstractView.extend({
+ display_name: _lt('Gantt'),
+ icon: 'fa-tasks',
+ config: {
+ Model: GanttModel,
+ Controller: Controller,
+ Renderer: GanttRenderer,
+ },
+ /**
+ * @override
+ */
+ init: function(viewInfo) {
+ this._super.apply(this, arguments);
+ var arch = viewInfo.arch;
+ this.loadParams.arch = arch;
+ },
+});
+
+return GanttView;
+
+});
diff --git a/addons/web/static/src/js/views/view_registry.js b/addons/web/static/src/js/views/view_registry.js
index bacc448d..6f6c33eb 100644
--- a/addons/web/static/src/js/views/view_registry.js
+++ b/addons/web/static/src/js/views/view_registry.js
@@ -31,6 +31,7 @@ var KanbanView = require('web.KanbanView');
var ListView = require('web.ListView');
var PivotView = require('web.PivotView');
var CalendarView = require('web.CalendarView');
+var GanttView = require('web.GanttView');
var view_registry = require('web.view_registry');
view_registry
@@ -39,6 +40,7 @@ view_registry
.add('kanban', KanbanView)
.add('graph', GraphView)
.add('pivot', PivotView)
- .add('calendar', CalendarView);
+ .add('calendar', CalendarView)
+ .add('gantt', GanttView);
});
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index e463ed5f..2dce0ab9 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -528,6 +528,39 @@
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index b1aaab00..93f8e62a 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -236,6 +236,18 @@