diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py
index 7c13814b73b..82ab8755fa2 100644
--- a/addons/project/__manifest__.py
+++ b/addons/project/__manifest__.py
@@ -63,9 +63,12 @@
'project/static/src/js/tours/project.js',
'project/static/src/js/project_calendar.js',
'project/static/src/js/burndown_chart/*',
+ 'project/static/src/js/right_panel/*',
+ 'project/static/src/js/update/*',
'project/static/src/js/widgets/*',
'project/static/src/scss/project_dashboard.scss',
'project/static/src/scss/project_form.scss',
+ 'project/static/src/scss/project_rightpanel.scss',
'project/static/src/scss/project_widgets.scss',
],
'web.assets_frontend': [
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index c241e0232ac..9dc4dde76aa 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -2,13 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
+import json
from collections import defaultdict
from datetime import timedelta, datetime
from random import randint
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, AccessError, ValidationError, RedirectWarning
-from odoo.tools.misc import format_date, get_lang
from odoo.osv.expression import OR
from .project_task_recurrence import DAYS, WEEKS
@@ -510,6 +510,58 @@ class Project(models.Model):
'color': self.last_update_color,
}
+ def get_panel_data(self):
+ self.ensure_one()
+ return {
+ 'user': self._get_user_values(),
+ 'tasks_analysis': self._get_tasks_analysis(),
+ 'milestones': self._get_milestones(),
+ }
+
+ def _get_user_values(self):
+ return {
+ 'is_project_manager': self.user_has_groups('project.group_project_manager'),
+ }
+
+ def _get_tasks_analysis(self):
+ self.ensure_one()
+ counts = self._get_tasks_analysis_counts(updated=True)
+ data = [{
+ 'name': _("Open Tasks"),
+ 'action': {
+ 'action': "project.action_project_task_user_tree",
+ 'additional_context': json.dumps({
+ 'search_default_project_id': self.id,
+ 'active_id': self.id,
+ 'search_default_open_tasks': True,
+ 'search_default_Stage': True,
+ 'search_default_User': True
+ }),
+ },
+ 'value': counts['open_tasks_count']
+ }, {
+ 'name': _("Updated (Last 30 Days)"),
+ 'action': {
+ 'action': "project.action_project_task_burndown_chart_report",
+ 'additional_context': json.dumps({
+ 'search_default_project_id': self.id,
+ 'active_id': self.id,
+ 'search_default_last_month': 1,
+ 'graph_mode': 'bar'
+ }),
+ },
+ 'value': counts['updated_tasks_count']
+ }]
+ return {
+ 'data': data,
+ }
+
+ def _get_milestones(self):
+ self.ensure_one()
+ return {
+ 'data': self.milestone_ids._get_data_list(),
+ }
+
def _get_tasks_analysis_counts(self, created=False, updated=False):
tasks = self.env['project.task'].search([('display_project_id', '=', self.id)])
open_tasks_count = created_tasks_count = updated_tasks_count = 0
diff --git a/addons/project/models/project_milestone.py b/addons/project/models/project_milestone.py
index 4c97081ba38..c1da37003ef 100644
--- a/addons/project/models/project_milestone.py
+++ b/addons/project/models/project_milestone.py
@@ -38,6 +38,11 @@ class ProjectMilestone(models.Model):
for ms in self:
ms.is_deadline_future = ms.deadline and ms.deadline > fields.Date.context_today(self)
+ def toggle_is_reached(self, is_reached):
+ self.ensure_one()
+ self.update({'is_reached': is_reached})
+ return self._get_data()
+
@api.model
def _get_fields_to_export(self):
return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future']
diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml
index 3010a6af12a..36f688b540d 100644
--- a/addons/project/report/project_report_views.xml
+++ b/addons/project/report/project_report_views.xml
@@ -38,6 +38,7 @@
+
@@ -48,8 +49,8 @@
-
+
diff --git a/addons/project/static/src/js/right_panel/project_right_panel.js b/addons/project/static/src/js/right_panel/project_right_panel.js
new file mode 100644
index 00000000000..50f233e71f5
--- /dev/null
+++ b/addons/project/static/src/js/right_panel/project_right_panel.js
@@ -0,0 +1,69 @@
+/** @odoo-module **/
+
+import { AddMilestone, OpenMilestone } from '@project/js/right_panel/project_utils';
+const { useState } = owl.hooks;
+
+export default class ProjectRightPanel extends owl.Component {
+ constructor() {
+ super(...arguments);
+ this.context = this.props.action.context;
+ this.domain = this.props.action.domain;
+ this.project_id = this.context.active_id;
+ this.state = useState({
+ data: {
+ tasks_analysis: {
+ data: []
+ },
+ milestones: {
+ data: []
+ },
+ user: {},
+ }
+ });
+ }
+
+ async willStart() {
+ await super.willStart(...arguments);
+ await this._loadQwebContext();
+ }
+
+ async willUpdateProps() {
+ await super.willUpdateProps(...arguments);
+ await this._loadQwebContext();
+ }
+
+ async _loadQwebContext() {
+ const data = await this.rpc({
+ model: 'project.project',
+ method: 'get_panel_data',
+ args: [this.project_id],
+ });
+ this.state.data = data;
+ return data;
+ }
+
+ async onProjectActionClick(event) {
+ let action = event.currentTarget.dataset.action;
+ const additionalContext = JSON.parse(event.currentTarget.dataset.additional_context || "{}");
+ if (event.currentTarget.dataset.type === "object") {
+ action = await this.rpc({
+ model: 'project.project',
+ method: event.currentTarget.dataset.action,
+ args: [this.project_id]
+ });
+ }
+ this._doAction(action, {
+ additional_context: additionalContext
+ });
+ }
+
+ _doAction(action, options) {
+ this.trigger('do-action', {
+ action,
+ options
+ });
+ }
+}
+
+ProjectRightPanel.template = "project.ProjectRightPanel";
+ProjectRightPanel.components = {AddMilestone, OpenMilestone};
diff --git a/addons/project/static/src/js/right_panel/project_right_panel_mixin.js b/addons/project/static/src/js/right_panel/project_right_panel_mixin.js
new file mode 100644
index 00000000000..863fd64bdff
--- /dev/null
+++ b/addons/project/static/src/js/right_panel/project_right_panel_mixin.js
@@ -0,0 +1,70 @@
+/** @odoo-module **/
+
+import { ComponentWrapper } from 'web.OwlCompatibility';
+
+export const RightPanelRendererMixin = {
+ /**
+ * Add RightPanel css class to renderer in order to handle panel style.
+ *
+ * @override
+ */
+ async start() {
+ this.$el.addClass('o_renderer_with_rightpanel');
+ await Promise.all([this._render(), this._super()]);
+ },
+};
+
+export const RightPanelControllerMixin = {
+ /**
+ * Init the rightSidePanel from the config parameters
+ *
+ * @override
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.rightSidePanel = params.rightSidePanel;
+ },
+ /**
+ * Add the Rightpanel component through the component wrapper.
+ *
+ * @override
+ */
+ start: async function () {
+ const promises = [this._super(...arguments)];
+ this._rightPanelWrapper = new ComponentWrapper(this, this.rightSidePanel.Component, this.rightSidePanel.props);
+ const content = this.el.querySelector(':scope .o_content');
+ content.classList.add('o_controller_with_rightpanel');
+ promises.push(this._rightPanelWrapper.mount(content, { position: 'last-child' }));
+ await Promise.all(promises);
+ },
+ /**
+ * Make sure the right panel is updated each time the view is reloaded.
+ *
+ * @override
+ */
+ async _update() {
+ this._rightPanelWrapper.update(this.rightSidePanel.props);
+ await this._super.apply(this, arguments);
+ },
+};
+
+export const RightPanelViewMixin = {
+ searchMenuTypes: ['filter', 'favorite'],
+ /**
+ * Adds the rightSidePanel in the view.
+ * This extention is assuming the rightSidePanel is always given in the case this Mixin is used.
+ *
+ * @override
+ */
+ _createSearchModel: function (params) {
+ const result = this._super.apply(this, arguments);
+ const props = {
+ action: params.action,
+ };
+ this.controllerParams.rightSidePanel = {
+ Component: this.config.RightSidePanel,
+ props: props,
+ };
+ return result;
+ }
+};
diff --git a/addons/project/static/src/js/right_panel/project_utils.js b/addons/project/static/src/js/right_panel/project_utils.js
new file mode 100644
index 00000000000..aa7a2f7fc8c
--- /dev/null
+++ b/addons/project/static/src/js/right_panel/project_utils.js
@@ -0,0 +1,144 @@
+/** @odoo-module **/
+
+import { _lt } from 'web.core';
+import fieldUtils from 'web.field_utils';
+import { ComponentAdapter } from 'web.OwlCompatibility';
+import { FormViewDialog } from 'web.view_dialogs';
+const { useState, useRef } = owl.hooks;
+
+export class FormViewDialogComponentAdapter extends ComponentAdapter {
+ renderWidget() {
+ // Ensure the dialog is properly reconstructed. Without this line, it is
+ // impossible to open the dialog again after having it closed a first
+ // time, because the DOM of the dialog has disappeared.
+ return this.willStart();
+ }
+}
+
+const components = {
+ FormViewDialogComponentAdapter,
+};
+
+class MilestoneComponent extends owl.Component {
+ constructor() {
+ super(...arguments);
+ this.contextValue = Object.assign({}, {
+ 'default_project_id': this.props.context.active_id,
+ }, this.props.context);
+ this.FormViewDialog = FormViewDialog;
+ this.state = useState({
+ openDialog: false
+ });
+ this._dialogRef = useRef('dialog');
+ this._isDialogOpen = false;
+ this._onDialogSaved = this._onDialogSaved.bind(this);
+ }
+
+ get context() {
+ return this.contextValue;
+ }
+
+ set context(value) {
+ this.contextValue = Object.assign({}, {
+ 'default_project_id': value.active_id,
+ }, value);
+ }
+
+ dialogClosed() {
+ this._isDialogOpen = false;
+ this.state.openDialog = false;
+ }
+
+ patched() {
+ if (this.state.openDialog && !this._isDialogOpen) {
+ this._isDialogOpen = true;
+ this._dialogRef.comp.widget.on('closed', this, () => {
+ this.dialogClosed();
+ });
+ this._dialogRef.comp.widget.open();
+ }
+ }
+
+ async _onDialogSaved() {
+ await this.__owl__.parent.willUpdateProps();
+ }
+}
+MilestoneComponent.components = components;
+
+export class AddMilestone extends MilestoneComponent {
+ get NEW_PROJECT_MILESTONE() {
+ return _lt("New Milestone");
+ }
+
+ onAddMilestoneClick() {
+ if (!this._isDialogOpen) {
+ this.state.openDialog = true;
+ }
+ }
+}
+AddMilestone.template = 'project.AddMilestone';
+
+export class OpenMilestone extends MilestoneComponent {
+
+ constructor() {
+ super(...arguments);
+ this.milestone = useState(this.props.milestone);
+ this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "";
+ this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square" : "fa-square-o";
+ }
+
+ get OPEN_PROJECT_MILESTONE() {
+ return _lt("Milestone");
+ }
+
+ get deadline() {
+ return fieldUtils.format.date(moment(this.milestone.deadline));
+ }
+
+ willUpdateProps(nextProps) {
+ if (nextProps.milestone) {
+ this.milestone = nextProps.milestone;
+ this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "";
+ this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square" : "fa-square-o";
+ }
+ if (nextProps.context) {
+ this.contextValue = nextProps.context;
+ }
+ }
+
+ dialogClosed() {
+ super.dialogClosed();
+ this.write_mutex = false;
+ }
+
+ async onDeleteMilestone() {
+ await this.rpc({
+ model: 'project.milestone',
+ method: 'unlink',
+ args: [this.milestone.id]
+ });
+ await this.__owl__.parent.willUpdateProps();
+ }
+
+ onOpenMilestone() {
+ if (!this._isDialogOpen && !this.write_mutex) {
+ this.write_mutex = true;
+ this.state.openDialog = true;
+ }
+ }
+
+ async onMilestoneClick() {
+ if (!this.write_mutex) {
+ this.write_mutex = true;
+ this.milestone = await this.rpc({
+ model: 'project.milestone',
+ method: 'toggle_is_reached',
+ args: [[this.milestone.id], !this.milestone.is_reached],
+ });
+ this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "";
+ this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square" : "fa-square-o";
+ this.write_mutex = false;
+ }
+ }
+}
+OpenMilestone.template = 'project.OpenMilestone';
diff --git a/addons/project/static/src/js/update/project_update_view_kanban.js b/addons/project/static/src/js/update/project_update_view_kanban.js
new file mode 100644
index 00000000000..d208e2b7337
--- /dev/null
+++ b/addons/project/static/src/js/update/project_update_view_kanban.js
@@ -0,0 +1,26 @@
+/** @odoo-module **/
+
+import KanbanController from 'web.KanbanController';
+import KanbanRenderer from 'web.KanbanRenderer';
+import KanbanView from 'web.KanbanView';
+import viewRegistry from 'web.view_registry';
+import ProjectRightSidePanel from '@project/js/right_panel/project_right_panel';
+import {
+ RightPanelControllerMixin,
+ RightPanelRendererMixin,
+ RightPanelViewMixin,
+} from '@project/js/right_panel/project_right_panel_mixin';
+
+const ProjectUpdateKanbanRenderer = KanbanRenderer.extend(RightPanelRendererMixin);
+
+const ProjectUpdateKanbanController = KanbanController.extend(RightPanelControllerMixin);
+
+export const ProjectUpdateKanbanView = KanbanView.extend(RightPanelViewMixin).extend({
+ config: Object.assign({}, KanbanView.prototype.config, {
+ Controller: ProjectUpdateKanbanController,
+ Renderer: ProjectUpdateKanbanRenderer,
+ RightSidePanel: ProjectRightSidePanel,
+ }),
+});
+
+viewRegistry.add('project_update_kanban', ProjectUpdateKanbanView);
diff --git a/addons/project/static/src/js/update/project_update_view_list.js b/addons/project/static/src/js/update/project_update_view_list.js
new file mode 100644
index 00000000000..a1972a2fbad
--- /dev/null
+++ b/addons/project/static/src/js/update/project_update_view_list.js
@@ -0,0 +1,26 @@
+/** @odoo-module **/
+
+import ListController from 'web.ListController';
+import ListRenderer from 'web.ListRenderer';
+import ListView from 'web.ListView';
+import viewRegistry from 'web.view_registry';
+import ProjectRightSidePanel from '@project/js/right_panel/project_right_panel';
+import {
+ RightPanelControllerMixin,
+ RightPanelRendererMixin,
+ RightPanelViewMixin,
+} from '@project/js/right_panel/project_right_panel_mixin';
+
+const ProjectUpdateListRenderer = ListRenderer.extend(RightPanelRendererMixin);
+
+const ProjectUpdateListController = ListController.extend(RightPanelControllerMixin);
+
+export const ProjectUpdateListView = ListView.extend(RightPanelViewMixin).extend({
+ config: Object.assign({}, ListView.prototype.config, {
+ Controller: ProjectUpdateListController,
+ Renderer: ProjectUpdateListRenderer,
+ RightSidePanel: ProjectRightSidePanel,
+ }),
+});
+
+viewRegistry.add('project_update_list', ProjectUpdateListView);
diff --git a/addons/project/static/src/scss/project_rightpanel.scss b/addons/project/static/src/scss/project_rightpanel.scss
new file mode 100644
index 00000000000..88f3e3f9836
--- /dev/null
+++ b/addons/project/static/src/scss/project_rightpanel.scss
@@ -0,0 +1,119 @@
+// ------- View with rightpanel -------
+$o-rightpanel-p: $o-horizontal-padding;
+$o-rightpanel-p-small: $o-horizontal-padding*0.5;
+$o-rightpanel-p-tiny: $o-rightpanel-p-small*0.5;
+
+$o-rightpanel-category-default-color: $o-brand-primary;
+$o-rightpanel-filter-default-color: #D59244;
+
+.o_controller_with_rightpanel {
+ overflow: hidden !important;
+ display: flex;
+ align-items: flex-start;
+
+ .o_renderer_with_rightpanel {
+ flex: 1 1 100%;
+ overflow: auto; // make the renderer and search panel scroll individually
+ max-height: 100%;
+ position: relative;
+ }
+}
+
+.o_rightpanel {
+ flex: 0 0 30%;
+ overflow: auto;
+ height: 100%;
+ padding: $o-rightpanel-p-small $o-rightpanel-p $o-rightpanel-p*2 $o-rightpanel-p;
+ border-left: 1px solid $gray-300;
+ background-color: white;
+
+
+ .o_rightpanel_section {
+ padding-bottom: $o-rightpanel-p;
+ padding-top: $o-rightpanel-p;
+
+ &:not(:first-child){
+ border-top: 1px solid $gray-300;
+ }
+
+ .o_rightpanel_header {
+ padding: $o-rightpanel-p-tiny 0 $o-rightpanel-p-small 0;
+
+ .o_rightpanel_title {
+ cursor: default;
+ padding: 8px 0;
+ font-weight: bold;
+ font-size: large;
+ display:inline;
+ }
+
+ .o_rightpanel_button {
+ cursor: pointer;
+ font-weight: bold;
+ font-size: small;
+ color: $o-brand-primary;
+ float: right;
+ padding-top: $o-rightpanel-p-tiny;
+ }
+ }
+
+ .o_rightpanel_data {
+ font-size: small;
+ .o_rightpanel_data_row {
+ &:hover {
+ background-color: $gray-200;
+ border-radius: 5px;
+ }
+ }
+ .o_rightpanel_left_col {
+ font-size: small;
+ padding-top: 0;
+ padding-bottom: 0;
+ &.o_rightpanel_left_button {
+ color: $o-brand-primary;
+ padding-left: 0;
+ }
+ &.o_rightpanel_left_text {
+ padding-right: $o-rightpanel-p-small;
+ }
+ }
+ .o_rightpanel_right_col {
+ float: right;
+ }
+
+ .o_open_milestone {
+ margin-bottom: $o-rightpanel-p-tiny;
+ display: flex;
+ &.o_milestone_danger {
+ color: red;
+ }
+ .o_milestone_checkbox {
+ cursor: pointer;
+ padding-right: $o-rightpanel-p-tiny;
+ }
+ .o_milestone_detail {
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ flex-grow: 1;
+ }
+ .o_delete_icon {
+ cursor: pointer;
+ padding-left: $o-rightpanel-p-tiny;
+ }
+ }
+ }
+
+ .o_rightpanel_hidden {
+ display: none;
+ }
+
+ .o_color_red {
+ color: $red;
+ }
+ .o_color_green {
+ color: $green;
+ }
+ }
+
+}
diff --git a/addons/project/static/src/xml/project_templates.xml b/addons/project/static/src/xml/project_templates.xml
index 961ac9cc9f3..79eda9dbb02 100644
--- a/addons/project/static/src/xml/project_templates.xml
+++ b/addons/project/static/src/xml/project_templates.xml
@@ -36,4 +36,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Track major progress points that must be reached to achieve success.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/project/views/project_update_views.xml b/addons/project/views/project_update_views.xml
index 4ec2a652774..1c5e1fc63c1 100644
--- a/addons/project/views/project_update_views.xml
+++ b/addons/project/views/project_update_views.xml
@@ -50,7 +50,7 @@
project.update.view.kanban
project.update
-
+
@@ -84,6 +84,21 @@
+
+ project.update.view.tree
+ project.update
+
+
+
+
+
+
+
+
+
+
+
+
Project Updates
ir.actions.act_window