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 @@ + +
+ +
+
+
+ Tasks Analysis +
+
+ +
+
+ + + + + +
+
+
+ +
+
+
+ Milestones +
+ + + +
+
+
+ +
+ + Track major progress points that must be reached to achieve success. + +
+
+
+
+ + +
+ Add Milestone + + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + + +
+
+ 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