[REF] project,*: convert kanban, list and form views in OWL

Before this commit, all the custom code for the widgets, form, list and
kanban views are always in OWL and have to be migrate to the new JS
framework.

This commit converts all the widgets, list, kanban and form views used
in the project app in OWL. Some JS tours has been adapted according to
the OWL views, the project right side panel has been reviewed since it
was LegacyComponent (in old component in OWL)

task-2944742

Part-of: odoo/odoo#98380
This commit is contained in:
Xavier BOL (xbo)
2022-08-30 20:22:34 +02:00
parent 15f2d75bdb
commit b8ef0cc4f2
93 changed files with 1729 additions and 1644 deletions
+6 -9
View File
@@ -59,16 +59,16 @@
'post_init_hook': '_project_post_init',
'assets': {
'web.assets_backend': [
'project/static/src/burndown_chart/*',
'project/static/src/project_control_panel/*',
'project/static/src/css/project.css',
'project/static/src/utils/**/*',
'project/static/src/services/**/*',
'project/static/src/components/**/*',
'project/static/src/views/**/*',
'project/static/src/js/project_activity.js',
'project/static/src/js/project_control_panel.js',
'project/static/src/js/project_form.js',
'project/static/src/js/project_form_html_field_expander_mixin.js',
'project/static/src/js/project_graph_view.js',
'project/static/src/js/project_kanban.js',
'project/static/src/js/project_list.js',
'project/static/src/js/project_pivot_view.js',
'project/static/src/js/project_rating_graph_view.js',
'project/static/src/js/project_rating_pivot_view.js',
@@ -76,12 +76,9 @@
'project/static/src/js/subtasks_list_renderer.js',
'project/static/src/js/tours/project.js',
'project/static/src/js/project_calendar.js',
'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_backend_legacy_lazy": [
@@ -93,8 +90,8 @@
],
'web.assets_qweb': [
'project/static/src/xml/**/*',
'project/static/src/burndown_chart/**/*.xml',
'project/static/src/project_control_panel/**/*.xml',
'project/static/src/components/**/*.xml',
'project/static/src/views/**/*.xml',
],
'web.qunit_suite_tests': [
'project/static/tests/project_test_utils.js',
+5
View File
@@ -850,6 +850,11 @@ class Project(models.Model):
panel_data['profitability_labels'] = self._get_profitability_labels()
return panel_data
def get_milestones(self):
if self.user_has_groups('project.group_project_user'):
return self._get_milestones()
return {}
def _get_profitability_labels(self):
return {}
@@ -0,0 +1,9 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
export class ProjectPrivateTaskMany2OneField extends Many2OneField { }
ProjectPrivateTaskMany2OneField.template = 'project.ProjectPrivateTaskMany2OneField';
registry.category('fields').add('project_private_task', ProjectPrivateTaskMany2OneField);
@@ -0,0 +1,4 @@
.project_private_task_many2one_field input::placeholder {
color: $red;
font-style: italic;
}
@@ -0,0 +1,21 @@
<templates>
<t t-name="project.ProjectPrivateTaskMany2OneField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-if='!props.canOpen']/span" position="attributes">
<attribute name="t-if">props.value</attribute>
</xpath>
<xpath expr="//t[@t-if='!props.canOpen']/span" position="after">
<span t-else="" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</xpath>
<xpath expr="//t[@t-else='']/a" position="attributes">
<attribute name="t-if">displayName</attribute>
</xpath>
<xpath expr="//t[@t-else='']/a" position="after">
<span t-else="" class="text-danger fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</xpath>
<xpath expr="//div[hasclass('o_field_many2one_selection')]" position="attributes">
<attribute name="class" separator=" " add="project_private_task_many2one_field"></attribute>
</xpath>
</t>
</templates>
@@ -0,0 +1,90 @@
/** @odoo-module */
import { formatDate } from "@web/core/l10n/dates";
import { useService } from '@web/core/utils/hooks';
const { Component, useState, onWillUpdateProps } = owl;
const { DateTime } = luxon;
export class ProjectMilestone extends Component {
setup() {
this.orm = useService('orm');
this.milestone = useState(this.props.milestone);
this.state = useState({
colorClass: this._getColorClass(),
checkboxIcon: this._getCheckBoxIcon(),
});
onWillUpdateProps(this.onWillUpdateProps);
}
get resModel() {
return 'project.milestone';
}
get deadline() {
if (!this.milestone.deadline) return;
return formatDate(DateTime.fromISO(this.milestone.deadline));
}
_getColorClass() {
return this.milestone.is_deadline_exceeded && !this.milestone.can_be_marked_as_done ? "o_milestone_danger" : this.milestone.can_be_marked_as_done ? "o_color_green" : "";
}
_getCheckBoxIcon() {
return this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o";
}
onWillUpdateProps(nextProps) {
if (nextProps.milestone) {
this.milestone = nextProps.milestone;
this.state.colorClass = this._getColorClass();
this.state.checkboxIcon = this._getCheckBoxIcon();
}
if (nextProps.context) {
this.contextValue = nextProps.context;
}
}
async onDeleteMilestone() {
await this.orm.call('project.milestone', 'unlink', [this.milestone.id]);
await this.props.load();
}
async onOpenMilestone() {
if (!this.write_mutex) {
this.write_mutex = true;
this.props.open({
resModel: this.resModel,
resId: this.milestone.id,
title: this.env._t("Milestone"),
}, {
onClose: async () => {
await this.props.load();
this.write_mutex = false;
},
});
}
}
async toggleIsReached() {
if (!this.write_mutex) {
this.write_mutex = true;
this.milestone = await this.orm.call(
this.resModel,
'toggle_is_reached',
[[this.milestone.id], !this.milestone.is_reached],
);
this.state.colorClass = this._getColorClass();
this.state.checkboxIcon = this._getCheckBoxIcon();
this.write_mutex = false;
}
}
}
ProjectMilestone.props = {
context: Object,
milestone: Object,
open: Function,
load: Function,
};
ProjectMilestone.template = 'project.ProjectMilestone';
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="project.ProjectMilestone" owl="1">
<div class="o_rightpanel_milestone" t-att-class="state.colorClass">
<span class="o_milestone_checkbox" t-on-click="toggleIsReached">
<i class="fa" t-att-class="state.checkboxIcon"></i>
</span>
<div class="o_milestone_detail" t-on-click="onOpenMilestone">
<div class="o_milestone_info">
<t t-esc="milestone.name"/>
</div>
<span class="o_rightpanel_center_col">
<t t-esc="deadline"/>
</span>
</div>
<span class="o_rightpanel_right_col">
<a class="o_delete_icon" t-on-click="onDeleteMilestone" title="Delete Milestone"><i class="fa fa-trash"></i></a>
</span>
</div>
</t>
</templates>
@@ -0,0 +1,31 @@
/** @odoo-module */
const { Component } = owl;
export class ProjectProfitability extends Component {
get revenues() {
return this.props.data.revenues;
}
get costs() {
return this.props.data.costs;
}
get margin() {
const invoiced_billed = this.revenues.total.invoiced - this.costs.total.billed;
const to_invoice_to_bill = this.revenues.total.to_invoice - this.costs.total.to_bill;
return {
invoiced_billed,
to_invoice_to_bill,
total: invoiced_billed + to_invoice_to_bill,
};
}
}
ProjectProfitability.props = {
data: Object,
labels: Object,
formatMonetary: Function,
onClick: Function,
};
ProjectProfitability.template = 'project.ProjectProfitability';
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="project.ProjectProfitability" owl="1">
<div class="o_rightpanel_subsection" t-if="revenues.data.length">
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Revenues</th>
<th style="width: 20%; text-align: right;">Invoiced</th>
<th style="width: 20%; text-align: right;">To Invoice</th>
<th style="width: 20%; text-align: right;">Expected</th>
</tr>
</thead>
<tbody>
<tr t-foreach="revenues.data" t-as="revenue" t-key="revenue.id" t-if="revenue.invoiced !== 0 || revenue.to_invoice !== 0">
<t t-set="revenue_label" t-value="props.labels[revenue.id] or revenue.id"/>
<td>
<a t-if="revenue.action" class="o_rightpanel_button"
t-on-click="() => this.props.onClick(revenue.action)"
>
<t t-esc="revenue_label"/>
</a>
<t t-esc="revenue_label" t-else=""/>
</td>
<td align="right" t-attf-class="{{ revenue.invoiced === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced)"/></td>
<td align="right" t-attf-class="{{ revenue.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(revenue.to_invoice)"/></td>
<td align="right" t-attf-class="{{ revenue.invoiced + revenue.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced + revenue.to_invoice)"/></td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td align="right" t-attf-class="{{ revenues.total.invoiced === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(revenues.total.invoiced)"/></td>
<td align="right" t-attf-class="{{ revenues.total.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(revenues.total.to_invoice)"/></td>
<td align="right" t-attf-class="{{ revenues.total.invoiced + revenues.total.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(revenues.total.invoiced + revenues.total.to_invoice)"/></td>
</tr>
</tfoot>
</table>
</div>
<div class="o_rightpanel_subsection" t-if="costs.data.length">
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Costs</th>
<th style="width: 20%; text-align: right;">Billed</th>
<th style="width: 20%; text-align: right;">To Bill</th>
<th style="width: 20%; text-align: right;">Expected</th>
</tr>
</thead>
<tbody>
<tr t-foreach="costs.data" t-as="cost" t-key="cost.id" t-if="cost.billed !== 0 || cost.to_bill !== 0">
<t t-set="cost_label" t-value="props.labels[cost.id] or cost.id"/>
<td>
<a t-if="cost.action" class="o_rightpanel_button"
t-on-click="() => this.props.onClick(cost.action)"
>
<t t-esc="cost_label"/>
</a>
<t t-esc="cost_label" t-else=""/>
</td>
<td align="right" t-attf-class="{{ cost.billed === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(cost.billed)"/></td>
<td align="right" t-attf-class="{{ cost.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(cost.to_bill)"/></td>
<td align="right" t-attf-class="{{ cost.billed + cost.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(cost.billed + cost.to_bill)"/></td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td align="right" t-attf-class="{{ costs.total.billed === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(costs.total.billed)"/></td>
<td align="right" t-attf-class="{{ costs.total.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(costs.total.to_bill)"/></td>
<td align="right" t-attf-class="{{ costs.total.billed + costs.total.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="props.formatMonetary(costs.total.billed + costs.total.to_bill)"/></td>
</tr>
</tfoot>
</table>
</div>
<div class="o_rightpanel_subsection">
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Margin</th>
<th style="width: 20%; text-align: right;" t-att-class="margin.invoiced_billed &lt; 0 ? 'o_color_red' : 'o_color_green'"><t t-esc="props.formatMonetary(margin.invoiced_billed)"/></th>
<th style="width: 20%; text-align: right;" t-att-class="margin.to_invoice_to_bill &lt; 0 ? 'o_color_red' : 'o_color_green'"><t t-esc="props.formatMonetary(margin.to_invoice_to_bill)"/></th>
<th style="width: 20%; text-align: right;" t-att-class="margin.total &lt; 0 ? 'o_color_red' : 'o_color_green'"><t t-esc="props.formatMonetary(margin.total)"/></th>
</tr>
</thead>
</table>
</div>
</t>
</templates>
@@ -0,0 +1,26 @@
/** @odoo-module */
const { Component } = owl;
export class ProjectRightSidePanelSection extends Component { }
ProjectRightSidePanelSection.props = {
name: { type: String, optional: true },
header: { type: Boolean, optional: true },
show: Boolean,
showData: { type: Boolean, optional: true },
slots: {
type: Object,
shape: {
default: Object, // Content is not optional
header: { type: Object, optional: true },
title: { type: Object, optional: true },
},
},
};
ProjectRightSidePanelSection.defaultProps = {
header: true,
showData: true,
};
ProjectRightSidePanelSection.template = 'project.ProjectRightSidePanelSection';
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="project.ProjectRightSidePanelSection" owl="1">
<div class="o_rightpanel_section" t-att-name="props.name" t-if="props.show">
<div class="o_rightpanel_header" t-if="props.header">
<div class="o_rightpanel_title cursor-default" t-if="props.slots.title">
<t t-slot="title"/>
</div>
<t t-slot="header"/>
</div>
<div class="o_rightpanel_data" t-if="props.showData">
<t t-slot="default"/>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,165 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { formatFloat, formatMonetary } from '@web/views/fields/formatters';
import { session } from '@web/session';
import { ViewButton } from '@web/views/view_button/view_button';
import { FormViewDialog } from '@web/views/view_dialogs/form_view_dialog';
import { ProjectRightSidePanelSection } from './components/project_right_side_panel_section';
import { ProjectMilestone } from './components/project_milestone';
import { ProjectProfitability } from './components/project_profitability';
const { Component, onWillStart, useState } = owl;
export class ProjectRightSidePanel extends Component {
setup() {
this.orm = useService('orm');
this.actionService = useService('action');
this.dialog = useService('dialog');
this.state = useState({
data: {
milestones: {
data: [],
},
profitability_items: {
costs: { data: [], total: { billed: 0.0, to_bill: 0.0 } },
revenues: { data: [], total: { invoiced: 0.0, to_invoice: 0.0 } },
},
user: {},
currency_id: false,
}
});
onWillStart(() => this.loadData());
}
get context() {
return this.props.context;
}
get domain() {
return this.props.domain;
}
get projectId() {
return this.context.active_id;
}
get currencyId() {
return this.state.data.currency_id;
}
get sectionNames() {
return {
'milestones': this.env._t('Milestones'),
'profitability': this.env._t('Profitability'),
};
}
get showProjectProfitability() {
return !!this.state.data.profitability_items
&& (
this.state.data.profitability_items.revenues.data.length > 0
|| this.state.data.profitability_items.costs.data.length > 0
);
}
formatFloat(value) {
return formatFloat(value, { digits: [false, 1] });
}
formatMonetary(value, options = {}) {
const valueFormatted = formatMonetary(value, {
currencyId: this.currencyId,
...options,
'noSymbol': true,
});
const currency = session.currencies[this.currencyId];
if (!currency) {
return valueFormatted;
}
if (currency.position === "after") {
return `${valueFormatted}\u00A0${currency.symbol}`;
} else {
return `${currency.symbol}\u00A0${valueFormatted}`;
}
}
async loadData() {
if (!this.projectId) { // If this is called from notif, multiples updates but no specific project
return {};
}
const data = await this.orm.call(
'project.project',
'get_panel_data',
[[this.projectId]],
{ context: this.context },
);
this.state.data = data;
return data;
}
async loadMilestones() {
const milestones = await this.orm.call(
'project.project',
'get_milestones',
[[this.projectId]],
{ context: this.context },
);
this.state.data.milestones = milestones;
return milestones;
}
addMilestone() {
const context = {
...this.context,
'default_project_id': this.projectId,
};
this.openFormViewDialog({
context,
title: this.env._t('New Milestone'),
resModel: 'project.milestone',
onRecordSaved: async () => {
await this.loadMilestones();
},
});
}
async openFormViewDialog(params, options = {}) {
this.dialog.add(FormViewDialog, params, options);
}
async onProjectActionClick(params) {
this.actionService.doActionButton({
type: 'action',
resId: this.projectId,
context: this.context,
resModel: 'project.project',
...params,
});
}
_getStatButtonClickParams(statButton) {
return {
type: statButton.action_type,
name: statButton.action,
context: statButton.additional_context || '{}',
};
}
_getStatButtonRecordParams() {
return {
resId: this.projectId,
context: JSON.stringify(this.context),
resModel: 'project.project',
};
}
}
ProjectRightSidePanel.components = { ProjectRightSidePanelSection, ProjectMilestone, ViewButton, ProjectProfitability };
ProjectRightSidePanel.template = 'project.ProjectRightSidePanel';
ProjectRightSidePanel.props = {
context: Object,
domain: Array,
};
@@ -1,4 +1,3 @@
// ------- 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;
@@ -6,30 +5,37 @@ $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 {
.o_controller_with_rightpanel .o_content {
overflow: hidden;
display: flex;
align-items: flex-start;
justify-content: space-between;
.o_renderer_with_rightpanel {
flex: 1 1 100%;
overflow: auto; // make the renderer and search panel scroll individually
.o_renderer {
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto; // make the renderer and search panel scroll individually
max-height: 100%;
position: relative;
padding: 0;
.o_kanban_record {
width: 100%;
margin: 0;
border-top: 0;
border-right: 0;
}
}
}
html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_with_rightpanel {
overflow: hidden;
}
.o_rightpanel {
flex: 0 0 25%;
flex: 0 0 37%;
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;
min-width: 380px;
min-width: 400px;
max-width: 1140px;
.o_rightpanel_section {
padding-bottom: $o-rightpanel-p;
@@ -118,7 +124,7 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_
align-items: center;
}
.o_open_milestone {
.o_rightpanel_milestone {
margin-bottom: $o-rightpanel-p-small;
display: flex;
&.o_milestone_danger {
@@ -185,13 +191,13 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_
}
.oe_stat_button {
flex-basis: 33.333333%;
flex-basis: 25%;
border: 1px solid $border-color;
margin-top: -1px;
margin-left: -1px;
height: 57.5px;
&:nth-child(3n):not(:last-child){
&:nth-child(4n):not(:last-child){
border-right-width: 0px;
}
@@ -202,9 +208,9 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_
@media screen and (max-width: 1600px) {
.oe_stat_button {
flex-basis: 50%;
flex-basis: 33.33%;
&:nth-child(2n){
&:nth-child(3n):not(:last-child) {
border-right-width: 0px;
}
}
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="project.ProjectRightSidePanel" owl="1">
<div t-if="projectId" class="o_rightpanel">
<ProjectRightSidePanelSection
name="'stat_buttons'"
header="false"
show="!!state.data.buttons"
>
<div class="o_form_view">
<div class="oe_button_box o-form-buttonbox">
<t t-foreach="state.data.buttons" t-as="button" t-key="button.action">
<ViewButton
t-if="button.show"
defaultRank="'oe_stat_button'"
icon="`fa-${button.icon}`"
title="button.text"
clickParams="_getStatButtonClickParams(button)"
record="_getStatButtonRecordParams()"
>
<t t-set-slot="contents">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<t t-esc="button.number"/>
</span>
<span class="o_stat_text">
<t t-esc="button.text"/>
</span>
</div>
</t>
</ViewButton>
</t>
</div>
</div>
</ProjectRightSidePanelSection>
<ProjectRightSidePanelSection
name="'profitability'"
show="showProjectProfitability"
>
<t t-set-slot="title" owl="1">
Profitability
</t>
<ProjectProfitability
data="state.data.profitability_items"
labels="state.data.profitability_labels"
formatMonetary="formatMonetary.bind(this)"
onClick="(params) => this.onProjectActionClick(params)"
/>
</ProjectRightSidePanelSection>
<ProjectRightSidePanelSection
name="'milestones'"
show="!!state.data.milestones &amp;&amp; !!state.data.milestones.data"
>
<t t-set-slot="header" owl="1">
<span class="o_rightpanel_button">
<div class="o_add_milestone">
<a t-on-click="addMilestone">Add Milestone</a>
</div>
</span>
</t>
<t t-set-slot="title" owl="1">
Milestones
</t>
<div t-foreach="state.data.milestones.data" t-as="milestone" t-key="milestone.id" class="o_rightpanel_data_row">
<ProjectMilestone context="context" milestone="milestone" open.bind="openFormViewDialog" load.bind="loadMilestones"/>
</div>
<span t-if="state.data.milestones.data.length === 0" class="text-muted fst-italic">
Track major progress points that must be reached to achieve success.
</span>
</ProjectRightSidePanelSection>
</div>
<!-- If this is called from notif, multiples updates but no specific project -->
<div t-else=""/>
</t>
</templates>
@@ -0,0 +1,20 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { StateSelectionField } from '@web/views/fields/state_selection/state_selection_field';
import { STATUS_COLORS, STATUS_COLOR_PREFIX } from '../../utils/project_utils';
export class ProjectStateSelectionField extends StateSelectionField {
setup() {
super.setup();
this.colorPrefix = STATUS_COLOR_PREFIX;
this.colors = STATUS_COLORS;
}
get showLabel() {
return !this.props.hideLabel;
}
}
registry.category('fields').add('project_state_selection', ProjectStateSelectionField);
@@ -0,0 +1,3 @@
.o_field_status_with_color button.disabled {
color: black;
}
@@ -0,0 +1,25 @@
/** @odoo-module */
import { SelectionField } from '@web/views/fields/selection/selection_field';
import { registry } from '@web/core/registry';
import { STATUS_COLORS, STATUS_COLOR_PREFIX } from '../../utils/project_utils';
export class ProjectStatusWithColorSelectionField extends SelectionField {
setup() {
super.setup();
this.colorPrefix = STATUS_COLOR_PREFIX;
this.colors = STATUS_COLORS;
}
get currentValue() {
return this.props.value || this.options[0][0];
}
statusColor(value) {
return this.colors[value] ? this.colorPrefix + this.colors[value] : "";
}
}
ProjectStatusWithColorSelectionField.template = 'project.ProjectStatusWithColorSelectionField';
registry.category('fields').add('status_with_color', ProjectStatusWithColorSelectionField);
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project.ProjectStatusWithColorSelectionField" t-inherit="web.SelectionField" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-if='props.readonly']/span" position="replace">
<div class="d-flex align-items-center">
<span t-attf-class="o_status {{ statusColor(currentValue) }} "/>
<span class="ps-1" t-out="string" t-att-raw-value="value" />
</div>
</xpath>
</t>
</templates>
@@ -0,0 +1,14 @@
/** @odoo-module */
import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog';
export class ProjectStopRecurrenceConfirmationDialog extends ConfirmationDialog {
_continueRecurrence() {
if (this.props.continueRecurrence) {
this.props.continueRecurrence();
}
this.props.close();
}
}
ProjectStopRecurrenceConfirmationDialog.template = 'project.ProjectStopRecurrenceConfirmationDialog';
ProjectStopRecurrenceConfirmationDialog.props.continueRecurrence = { type: Function, optional: true };
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="project.ProjectStopRecurrenceConfirmationDialog" t-inherit="web.ConfirmationDialog" t-inherit-mode="primary" owl="1">
<xpath expr="//button[@t-on-click='_confirm']" position="replace">
<button class="btn btn-primary" t-on-click="_confirm">
Stop Recurrence
</button>
</xpath>
<xpath expr="//button[@t-on-click='_confirm']" position="after">
<button t-if="props.continueRecurrence" class="btn btn-secondary" t-on-click="_continueRecurrence">
Continue Recurrence
</button>
</xpath>
</t>
</templates>
@@ -0,0 +1,15 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { CharField } from '@web/views/fields/char/char_field';
import { formatChar } from '@web/views/fields/formatters';
class ProjectTaskNameWithSubtaskCountCharField extends CharField {
get formattedSubtaskCount() {
return formatChar(this.props.record.data.allow_subtasks && this.props.record.data.child_text || '');
}
}
ProjectTaskNameWithSubtaskCountCharField.template = 'project.ProjectTaskNameWithSubtaskCountCharField';
registry.category('fields').add('name_with_subtask_count', ProjectTaskNameWithSubtaskCountCharField);
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project.ProjectTaskNameWithSubtaskCountCharField" t-inherit="web.CharField" t-inherit-mode="primary" owl="1">
<xpath expr="//span[@t-esc='formattedValue']" position="after">
<span
class="text-muted ms-2"
t-out="formattedSubtaskCount"
style="{ font-weight: normal; }"
/>
</xpath>
</t>
</templates>
@@ -0,0 +1,20 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { ListRenderer } from '@web/views/list/list_renderer';
export class SubtaskListRenderer extends ListRenderer {
setup() {
super.setup();
this.dialog = useService("dialog");
}
async onDeleteRecord(record) {
this.dialog.add(ConfirmationDialog, {
body: this.env._t("Are you sure you want to delete this record?"),
confirm: () => super.onDeleteRecord(record),
cancel: () => {},
});
}
}
@@ -0,0 +1,19 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { X2ManyField } from '@web/views/fields/x2many/x2many_field';
import { SubtaskListRenderer } from './subtask_list_renderer';
export class SubtaskOne2ManyField extends X2ManyField {
get Renderer() {
return this.viewMode === 'list' ? SubtaskListRenderer : super.Renderer;
}
}
SubtaskOne2ManyField.components = {
...X2ManyField,
ListRenderer: SubtaskListRenderer,
}
registry.category("fields").add("subtasks_one2many", SubtaskOne2ManyField);
-42
View File
@@ -1,22 +1,3 @@
.o_form_project_tasks.o_form_editable .oe_title {
max-width: initial;
}
.o_form_project_tasks.o_form_editable .oe_title .o_task_name {
/*
* should be (coming from addons/web/static/src/legacy/scss/form_view.scss):
* max-width: map-get($container-max-widths, md) - (2 * $o-horizontal-padding);
*/
max-width: 688px;
}
.o_form_project_tasks .note-editable {
border: 0;
}
.o_form_project_tasks .o_wysiwyg_resizer {
border: 0;
}
.o_kanban_project_tasks .oe_kanban_align.badge {
background: inherit;
color: inherit;
@@ -27,26 +8,3 @@
margin-top:2px;
margin-right: 6px;
}
.o_form_project_tasks .ribbon:not(.o_invisible_modifier) + div > h1 > div[name="kanban_state"] {
margin-right: 150px;
padding-left: 1rem;
}
.o_form_project_recurrence_message *:last-child {
margin-bottom: 0;
}
.o_project_portal_warning_dl {
width: 100%;
padding: 0;
margin: 0
}
.o_project_portal_warning_dt {
width: 4%;
float: left;
}
.o_project_portal_warning_dd {
width: 96%;
float: left;
}
+1 -1
View File
@@ -188,6 +188,6 @@ export const ProjectFormView = FormDescriptionExpanderView.extend({
}),
});
viewRegistry.add('project_form', ProjectFormView);
viewRegistry.add('project_task_form', ProjectFormView);
viewRegistry.add('form_description_expander', FormDescriptionExpanderView)
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel";
import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel";
import { registry } from "@web/core/registry";
import { graphView } from "@web/views/graph/graph_view";
@@ -1,371 +0,0 @@
/** @odoo-module **/
import KanbanController from 'web.KanbanController';
import KanbanRenderer from 'web.KanbanRenderer';
import KanbanView from 'web.KanbanView';
import KanbanColumn from 'web.KanbanColumn';
import KanbanRecord from 'web.KanbanRecord';
import KanbanModel from 'web.KanbanModel';
import viewRegistry from 'web.view_registry';
import { ProjectControlPanel } from '@project/js/project_control_panel';
import viewUtils from 'web.viewUtils';
import { Domain } from '@web/core/domain';
import view_dialogs from 'web.view_dialogs';
import core from 'web.core';
const _t = core._t;
// PROJECTS
const ProjectProjectKanbanRecord = KanbanRecord.extend({
/**
* @override
* @private
*/
_openRecord: function () {
const kanbanBoxesElement = this.el.querySelectorAll('.o_project_kanban_boxes a');
if (this.selectionMode !== true && kanbanBoxesElement.length) {
kanbanBoxesElement[0].click();
} else {
this._super.apply(this, arguments);
}
},
/**
* @override
* @private
*/
_onManageTogglerClicked: function (event) {
this._super.apply(this, arguments);
const thisSettingToggle = this.el.querySelector('.o_kanban_manage_toggle_button');
this.el.parentNode.querySelectorAll('.o_kanban_manage_toggle_button.show').forEach(el => {
if (el !== thisSettingToggle) {
el.classList.remove('show');
}
});
thisSettingToggle.classList.toggle('show');
},
});
const ProjectProjectKanbanRenderer = KanbanRenderer.extend({
config: _.extend({}, KanbanRenderer.prototype.config, {
KanbanRecord: ProjectProjectKanbanRecord,
}),
});
const ProjectProjectKanbanView = KanbanView.extend({
config: Object.assign({}, KanbanView.prototype.config, {
Renderer: ProjectProjectKanbanRenderer,
})
});
viewRegistry.add('project_project_kanban', ProjectProjectKanbanView);
// TASKS
const ProjectTaskKanbanColumn = KanbanColumn.extend({
/**
* @override
* @private
*/
_onDeleteColumn: function (event) {
if (this.groupedBy === 'stage_id') {
event.preventDefault();
this.trigger_up('kanban_column_delete_wizard');
} else {
this._super(...arguments);
}
},
/**
* Open alternative view when editing personal stages.
*
* @private
* @override
*/
_onEditColumn: function (event) {
if (this.groupedBy !== 'personal_stage_type_ids') {
this._super(...arguments);
return;
}
event.preventDefault();
const context = Object.assign({}, this.getSession().user_context, {
form_view_ref: 'project.personal_task_type_edit',
});
new view_dialogs.FormViewDialog(this, {
res_model: this.relation,
res_id: this.id,
context: context,
title: _t("Edit Personal Stage"),
on_saved: this.trigger_up.bind(this, 'reload'),
}).open();
},
});
const ProjectTaskKanbanRenderer = KanbanRenderer.extend({
config: Object.assign({}, KanbanRenderer.prototype.config, {
KanbanColumn: ProjectTaskKanbanColumn,
}),
init: function () {
this._super.apply(this, arguments);
this.isProjectManager = false;
},
willStart: function () {
const superPromise = this._super.apply(this, arguments);
const isProjectManager = this.getSession().user_has_group('project.group_project_manager').then((hasGroup) => {
this.isProjectManager = hasGroup;
this._setState();
return Promise.resolve();
});
return Promise.all([superPromise, isProjectManager]);
},
/**
* Allows record drag when grouping by `personal_stage_type_ids`
*
* @override
*/
_setState() {
this._super(...arguments);
const groupedBy = this.state.groupedBy[0];
const groupByFieldName = viewUtils.getGroupByField(groupedBy);
const field = this.state.fields[groupByFieldName] || {};
const fieldInfo = this.state.fieldsInfo.kanban[groupByFieldName] || {};
const grouped_by_date = ["date", "datetime"].includes(field.type);
const grouped_by_m2m = field.type === "many2many";
const readonly = !!field.readonly || !!fieldInfo.readonly;
const groupedByPersonalStage = (groupByFieldName === 'personal_stage_type_ids');
const draggable = !readonly && (!grouped_by_m2m || groupedByPersonalStage) &&
(!grouped_by_date || fieldInfo.allowGroupRangeValue);
// When grouping by personal stage we allow any project user to create
let editable = this.columnOptions.editable;
let deletable = this.columnOptions.deletable;
if (['stage_id', 'personal_stage_type_ids'].includes(groupByFieldName)) {
this.groupedByM2O = groupedByPersonalStage || this.groupedByM2O;
const allow_crud = this.isProjectManager || groupedByPersonalStage;
this.createColumnEnabled = editable = deletable = allow_crud;
}
Object.assign(this.columnOptions, {
draggable,
grouped_by_m2o: this.groupedByM2O,
editable: editable,
deletable: deletable,
});
}
});
export const ProjectKanbanController = KanbanController.extend({
custom_events: Object.assign({}, KanbanController.prototype.custom_events, {
'kanban_column_delete_wizard': '_onDeleteColumnWizard',
}),
_onDeleteColumnWizard: function (ev) {
ev.stopPropagation();
const self = this;
const columnId = ev.target.id;
const state = this.model.get(this.handle, {raw: true});
this._rpc({
model: 'project.task.type',
method: 'unlink_wizard',
args: [columnId],
context: state.getContext(),
}).then(function (res) {
self.do_action(res);
});
},
/**
* @override
*/
_onDeleteColumn: function (ev) {
const state = this.model.get(this.handle, {raw: true});
const groupedByFieldname = state.groupedBy[0];
if (groupedByFieldname !== 'personal_stage_type_ids') {
this._super(...arguments);
return;
}
const column = ev.target;
this._rpc({
model: 'project.task.type',
method: 'remove_personal_stage',
args: [[column.id]],
}).then(this.update.bind(this, {}, {}));
},
});
const ProjectTaskKanbanModel = KanbanModel.extend({
/**
* Upon updating `personal_stage_type_ids` we actually want to update the `personal_stage_type_id` field.
*
* @override
* @private
*/
async moveRecord(recordID, groupID, parentID) {
const self = this;
const parent = this.localData[parentID];
const new_group = this.localData[groupID];
const changes = {};
const groupedFieldName = viewUtils.getGroupByField(parent.groupedBy[0]);
const groupedField = parent.fields[groupedFieldName];
// for a date/datetime field, we take the last moment of the group as the group value
if (['date', 'datetime'].includes(groupedField.type)) {
changes[groupedFieldName] = viewUtils.getGroupValue(new_group, parent.groupedBy[0]);
} else if (groupedField.type === 'many2one') {
changes[groupedFieldName] = {
id: new_group.res_id,
display_name: new_group.value,
};
} else if (groupedField.type === 'selection') {
const value = _.findWhere(groupedField.selection, {1: new_group.value});
changes[groupedFieldName] = value && value[0] || false;
} else if (groupedField.type == 'many2many' && groupedFieldName == 'personal_stage_type_ids') {
changes['personal_stage_type_id'] = {
id: new_group.res_id,
display_name: new_group.value,
}
} else {
changes[groupedFieldName] = new_group.value;
}
// Manually updates groups data. Note: this is done before the actual
// save as it might need to perform a read group in some cases so those
// updated data might be overridden again.
const record = self.localData[recordID];
const resID = record.res_id;
// Remove record from its current group
let old_group;
for (let i = 0; i < parent.data.length; i++) {
old_group = self.localData[parent.data[i]];
const index = _.indexOf(old_group.data, recordID);
if (index >= 0) {
old_group.data.splice(index, 1);
old_group.count--;
if (!old_group.activeFilter || old_group.activeFilter.value === record.data[parent.progressBar.field]) {
// Here, the record leaving the old group matches its domain,
// so we must decrease the domainCount too.
old_group.domainCount--;
}
old_group.res_ids = _.without(old_group.res_ids, resID);
self._updateParentResIDs(old_group);
break;
}
}
// Add record to its new group
new_group.data.push(recordID);
new_group.res_ids.push(resID);
new_group.count++;
await this.notifyChanges(recordID, changes);
await self.save(recordID);
if (groupedFieldName === 'stage_id') {
const action = await this._rpc({
model: 'project.task',
method: 'get_milestone_to_mark_as_reached_action',
args: [[resID]],
});
if (action) {
this.trigger_up('do-action', {
action,
});
}
}
record.parentID = new_group.id;
return [old_group.id, new_group.id];
},
/**
* When grouped by personal stage create a new personal stage instead of
* a regular stage.
* Meaning setting `user_id` on the stage.
*
* @override
*/
createGroup: function (name, parentID) {
const parent = this.localData[parentID];
const groupedFieldName = viewUtils.getGroupByField(parent.groupedBy[0]);
if (groupedFieldName !== 'personal_stage_type_ids') {
return this._super(...arguments);
}
const groupBy = parent.groupedBy[0];
const context = Object.assign({}, parent.context, {
default_user_id: this.getSession().uid,
});
// In case it's a personal stage we don't want to assign it to the project.
delete context.default_project_id;
return this._rpc({
model: 'project.task.type',
method: 'name_create',
args: [name],
context: context,
})
.then((result) => {
const createGroupDataPoint = (model, parent) => {
const newGroup = model._makeDataPoint({
modelName: parent.model,
context: parent.context,
domain: parent.domain.concat([[groupBy, "=", result[0]]]),
fields: parent.fields,
fieldsInfo: parent.fieldsInfo,
isOpen: true,
limit: parent.limit,
parentID: parent.id,
openGroupByDefault: true,
orderedBy: parent.orderedBy,
value: result,
viewType: parent.viewType,
});
if (parent.progressBar) {
newGroup.progressBarValues = _.extend({
counts: {},
}, parent.progressBar);
}
return newGroup;
};
const newGroup = createGroupDataPoint(this, parent);
parent.data.push(newGroup.id);
if (this.isInSampleMode()) {
// in sample mode, create the new group in both models (main + sample)
const sampleParent = this.sampleModel.localData[parentID];
const newSampleGroup = createGroupDataPoint(this.sampleModel, sampleParent);
sampleParent.data.push(newSampleGroup.id);
}
return newGroup.id;
});
},
/**
* Force tasks assigned to the user when grouping by personal stage.
*
* @override
* @private
*/
_readGroup: function (list) {
const groupedBy = list.groupedBy[0];
if (groupedBy === 'personal_stage_type_ids') {
list.domain = Domain.and([
[['user_ids', 'in', this.getSession().uid]],
JSON.stringify(list.domain),
]).toList();
}
return this._super(...arguments);
},
})
const ProjectKanbanView = KanbanView.extend({
config: _.extend({}, KanbanView.prototype.config, {
Model: ProjectTaskKanbanModel,
Controller: ProjectKanbanController,
Renderer: ProjectTaskKanbanRenderer,
ControlPanel: ProjectControlPanel,
}),
});
viewRegistry.add('project_task_kanban', ProjectKanbanView);
@@ -1,145 +0,0 @@
/** @odoo-module **/
import { _t } from 'web.core';
import Dialog from 'web.Dialog';
import ListView from 'web.ListView';
import ListController from 'web.ListController';
import viewRegistry from 'web.view_registry';
import { ProjectControlPanel } from '@project/js/project_control_panel';
const ProjectListController = ListController.extend({
_getActionMenuItems(state) {
if (!this.archiveEnabled) {
return this._super(...arguments);
}
const recurringRecords = this.getSelectedRecords().filter(rec => rec.data.recurrence_id).map(rec => rec.data.id);
this.archiveEnabled = recurringRecords.length == 0;
let actions = this._super(...arguments);
this.archiveEnabled = true;
if (actions && recurringRecords.length > 0) {
actions.items.other.unshift({
description: _t('Archive'),
callback: () => this._stopRecurrence(recurringRecords, this.selectedRecords, 'archive'),
}, {
description: _t('Unarchive'),
callback: () => this._toggleArchiveState(false)
});
}
return actions;
},
_onDeleteSelectedRecords() {
const recurringRecords = this.getSelectedRecords().filter(rec => rec.data.recurrence_id).map(rec => rec.data.id);
if (recurringRecords.length > 0) {
return this._stopRecurrence(recurringRecords, this.selectedRecords, 'delete');
}
return this._super(...arguments);
},
_countRecordsPerReccurence(recurrenceIds, resIds) {
return this._rpc({
model: 'project.task',
method: 'read_group',
args: [
[['recurrence_id', 'in', recurrenceIds], ['id', 'not in', resIds]],
['recurrence_id'],
['recurrence_id'],
],
});
},
async _stopRecurrence(recurringResIds, resIds, mode) {
const recurrenceIdsSet = new Set();
for (const record of this.getSelectedRecords()) {
const recurrenceId = record.data.recurrence_id;
if (recurrenceId) {
recurrenceIdsSet.add(recurrenceId);
}
}
const recurrenceIds = Array.from(recurrenceIdsSet);
// list recurrences that have tasks left after deleting/archiving
let countsLeft = await this._countRecordsPerReccurence(recurrenceIds, recurringResIds);
countsLeft = countsLeft.map(rec => rec.recurrence_id[0]);
// so we check that no recurrence is absent, as it would mean no task is left
const allowContinue = recurrenceIds.every(rec => countsLeft.includes(rec));
let warning;
if (resIds.length > 1) {
warning = allowContinue
? _t('It seems that some tasks are part of a recurrence.')
: _t('It seems that some tasks are part of a recurrence. At least one of them must be kept as a model to create the next occurences.');
} else {
warning = allowContinue
? _t('It seems that this task is part of a recurrence.')
: _t('It seems that this task is part of a recurrence. You must keep it as a model to create the next occurences.');
}
const dialog = new Dialog(this, {
buttons: [
{
classes: 'btn-primary',
click: () => {
this._rpc({
model: 'project.task',
method: 'action_stop_recurrence',
args: [recurringResIds],
}).then(() => {
if (mode === 'archive') {
this._toggleArchiveState(true);
} else if (mode === 'delete') {
this._deleteRecords(resIds);
}
});
},
close: true,
text: _t('Stop Recurrence'),
},
{
close: true,
text: _t('Discard'),
}
],
size: 'medium',
title: _t('Confirmation'),
$content: $('<main/>', {
role: 'alert',
text: warning,
}),
});
if (allowContinue) {
Dialog.buttons.splice(1, 0,
{
click: () => {
this._rpc({
model: 'project.task',
method: 'action_continue_recurrence',
args: [recurringResIds],
}).then(() => {
if (mode === 'archive') {
this._toggleArchiveState(true);
} else if (mode === 'delete') {
this._deleteRecords(resIds);
}
});
},
close: true,
text: _t('Continue Recurrence'),
});
}
dialog.open();
}
});
export const ProjectListView = ListView.extend({
config: Object.assign({}, ListView.prototype.config, {
Controller: ProjectListController,
ControlPanel: ProjectControlPanel,
}),
});
viewRegistry.add('project_list', ProjectListView);
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel";
import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel";
import { registry } from "@web/core/registry";
import { pivotView } from "@web/views/pivot/pivot_view";
@@ -1,120 +0,0 @@
/** @odoo-module **/
import { AddMilestone, OpenMilestone } from '@project/js/right_panel/project_utils';
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
import { LegacyComponent } from "@web/legacy/legacy_component";
import { session } from "@web/session";
const { onWillStart, onWillUpdateProps, useState } = owl;
export default class ProjectRightPanel extends LegacyComponent {
setup() {
this.context = this.props.action.context;
this.domain = this.props.action.domain;
this.project_id = this.context.active_id;
this.state = useState({
data: {
milestones: {
data: []
},
profitability_items: {
costs: { data: [], total: { billed: 0.0, to_bill: 0.0 } },
revenues: { data: [], total: { invoiced: 0.0, to_invoice: 0.0 } },
},
user: {},
}
});
onWillStart(() => this._loadQwebContext());
onWillUpdateProps(() => this._loadQwebContext());
}
formatFloat(value) {
return formatFloat(value, { digits: [false, 1] });
}
formatMonetary(value, options = {}) {
const valueFormatted = formatMonetary(value, {
currencyId: this.state.data.currency_id,
...options,
'noSymbol': true,
});
const currency = session.currencies[this.state.data.currency_id];
if (!currency) {
return valueFormatted;
}
if (currency.position === "after") {
return `${valueFormatted}\u00A0${currency.symbol}`;
} else {
return `${currency.symbol}\u00A0${valueFormatted}`;
}
}
async _loadQwebContext() {
if (!this.project_id){ // If this is called from notif, multiples updates but no specific project
return {};
}
const data = await this.rpc({
model: 'project.project',
method: 'get_panel_data',
args: [this.project_id],
kwargs: {
context: this.context
}
});
this.state.data = data;
return data;
}
async onProjectActionClick(event) {
event.stopPropagation();
const dataset = event.currentTarget.dataset;
let action = dataset.action;
const additionalContext = JSON.parse(dataset.additional_context || "{}");
if (dataset.type === "object") {
const args = [this.project_id];
if (dataset.section) {
args.push(dataset.section);
}
if (dataset.domain) {
args.push(JSON.parse(dataset.domain || '[]'));
}
if (dataset.resId) {
args.push(Number(dataset.resId));
}
const context = dataset.context ? JSON.parse(dataset.context) : {};
action = await this.rpc({
// Use the call_button method in order to have an action
// with the correct view naming, i.e. list view is named
// 'list' rather than 'tree'.
route: '/web/dataset/call_button',
params: {
model: 'project.project',
method: action,
args,
kwargs: {
context: Object.assign(this.context, context),
}
}
});
}
const options = {
additional_context: additionalContext,
};
if (dataset.res_id) {
options.res_id = Number(dataset.res_id);
options.view_type = 'form';
}
this._doAction(action, options);
}
_doAction(action, options) {
this.trigger('do-action', {
action,
options
});
}
}
ProjectRightPanel.template = "project.ProjectRightPanel";
ProjectRightPanel.components = {AddMilestone, OpenMilestone};
@@ -1,71 +0,0 @@
/** @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 = {
rightPanelPosition: 'last-child',
/**
* 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: this.rightPanelPosition }));
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;
}
};
@@ -1,129 +0,0 @@
/** @odoo-module **/
import fieldUtils from 'web.field_utils';
import { ComponentAdapter, standaloneAdapter } from 'web.OwlCompatibility';
import { FormViewDialog } from 'web.view_dialogs';
import { useService } from "@web/core/utils/hooks";
const { Component, onWillUpdateProps, useState } = owl;
export class ProjectRightSidePanelComponent extends Component {
setup() {
super.setup();
this.contextValue = Object.assign({}, {
'default_project_id': this.props.context.active_id,
}, this.props.context);
}
async openLegacyFormDialog(params) {
const adapterParent = standaloneAdapter({ Component });
const dialog = new FormViewDialog(adapterParent, {
context: this.contextValue,
res_id: false,
...params,
});
await dialog.open();
return dialog;
}
}
ProjectRightSidePanelComponent.components = { ComponentAdapter };
class MilestoneComponent extends ProjectRightSidePanelComponent {
async openLegacyFormDialog(params) {
return super.openLegacyFormDialog({
res_model: "project.milestone",
on_saved: this.props.onMilestoneUpdate,
...params,
});
}
}
export class AddMilestone extends MilestoneComponent {
onAddMilestoneClick(event) {
event.stopPropagation();
this.openLegacyFormDialog({
_createContext: () => {
return this.contextValue;
},
title: this.env._t("New Milestone"),
multi_select: true
});
}
}
AddMilestone.template = 'project.AddMilestone';
export class OpenMilestone extends MilestoneComponent {
setup() {
super.setup();
this.rpc = useService("rpc");
this.milestone = useState(this.props.milestone);
this.state = useState({
colorClass: this._getColorClass(),
checkboxIcon: this._getCheckBoxIcon(),
});
onWillUpdateProps(this.onWillUpdateProps);
}
get deadline() {
return fieldUtils.format.date(moment(this.milestone.deadline));
}
_getColorClass() {
return this.milestone.is_deadline_exceeded && !this.milestone.can_be_marked_as_done ? "o_milestone_danger" : this.milestone.can_be_marked_as_done ? "o_color_green" : "";
}
_getCheckBoxIcon() {
return this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o";
}
onWillUpdateProps(nextProps) {
if (nextProps.milestone) {
this.milestone = nextProps.milestone;
this.state.colorClass = this._getColorClass();
this.state.checkboxIcon = this._getCheckBoxIcon();
}
if (nextProps.context) {
this.contextValue = nextProps.context;
}
}
async onDeleteMilestone() {
await this.rpc({
model: 'project.milestone',
method: 'unlink',
args: [this.milestone.id]
});
await this.props.onMilestoneUpdate();
}
async onOpenMilestone() {
if (!this.write_mutex) {
this.write_mutex = true;
const dialog = await this.openLegacyFormDialog({
res_id: this.milestone.id,
title: this.env._t("Milestone"),
disable_multiple_selection: true,
deletable: true
});
dialog.on('closed', this, () => {
this.write_mutex = false;
});
}
}
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._getColorClass();
this.state.checkboxIcon = this._getCheckBoxIcon();
this.write_mutex = false;
}
}
}
OpenMilestone.template = 'project.OpenMilestone';
@@ -65,7 +65,7 @@ tour.register('project_tour', {
position: 'bottom',
width: 200,
}, {
trigger: '.o_kanban_quick_create input.o_field_char[name=name]',
trigger: '.o_kanban_quick_create div.o_field_char[name=name] input',
extra_trigger: '.o_kanban_project_tasks',
content: Markup(_t('Choose a task <b>name</b> <i>(e.g. Website Design, Purchase Goods...)</i>')),
position: 'right',
@@ -1,26 +0,0 @@
/** @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);
@@ -1,81 +0,0 @@
/** @odoo-module **/
import {qweb} from 'web.core';
import fieldRegistry from 'web.field_registry';
import {StateSelectionWidget} from 'web.basic_fields';
/**
* List of colors according to the selection value, see `project_update.py`
*/
const STATUS_COLORS = {
'on_track': 10,
'at_risk': 2,
'off_track': 1,
'on_hold': 4,
};
export const ProjectStateSelectionWidget = StateSelectionWidget.extend({
/**
* @override
*/
init() {
this._super.apply(this, arguments);
// The dropdown never disappears without `edit` mode on.
this.mode = 'edit';
},
/**
* @override
* @private
*/
_prepareDropdownValues() {
const self = this;
const _data = [];
const current_state_id = self.recordData.last_update_status;
_.map(this.field.selection || [], function(selection_item) {
// Exclude `to_define` from the dropdown list
if (selection_item[0] === 'to_define' && current_state_id !== 'to_define') {
return;
}
const value = {
'name': selection_item[0],
'tooltip': selection_item[1],
};
value.state_class = 'o_status_bubble mx-0 o_color_bubble_' + STATUS_COLORS[selection_item[0]];
value.state_name = selection_item[1];
_data.push(value);
});
return _data;
},
/**
* @override
* @private
*/
_render() {
// Complete override since most of `StateSelectionWidget` is hardcoded.
// See `_render` from `StateSelectionWidget`
const states = this._prepareDropdownValues();
const currentState = _.findWhere(states, {name: this.value}) || states[0];
this.$('.o_status')
.removeClass('o_status_bubble mx-0 o_color_bubble_10 o_color_bubble_2 o_color_bubble_1 o_color_bubble_4')
.addClass(currentState.state_class)
.prop('special_click', true)
.parent().attr('title', currentState.state_name)
.attr('aria-label', this.string + ": " + currentState.state_name);
const $items = $(qweb.render('FormSelection.items', {
states: _.without(states, currentState)
}));
const $dropdown = this.$('.dropdown-menu');
$dropdown.children().remove();
$items.appendTo($dropdown);
const isReadonly = this.record.evalModifiers(this.attrs.modifiers).readonly;
this.$('a[data-bs-toggle=dropdown]').toggleClass('disabled', isReadonly || false);
}
});
fieldRegistry.add('project_state_selection', ProjectStateSelectionWidget);
@@ -4,22 +4,23 @@ import { useBus, useService } from '@web/core/utils/hooks';
import { ActionContainer } from '@web/webclient/actions/action_container';
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useOwnDebugContext } from "@web/core/debug/debug_context";
import { ErrorHandler } from "@web/core/utils/components";
import { session } from '@web/session';
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useEffect } = owl;
const { Component, useEffect, useExternalListener, useState } = owl;
export class ProjectSharingWebClient extends LegacyComponent {
export class ProjectSharingWebClient extends Component {
setup() {
window.parent.document.body.style.margin = "0"; // remove the margin in the parent body
this.actionService = useService('action');
this.user = useService("user");
useService("legacy_service_provider");
useOwnDebugContext({ categories: ["default"] });
this.state = useState({
fullscreen: false,
});
useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", (mode) => {
if (mode !== "new") {
this.el.classList.toggle("o_fullscreen", mode === "fullscreen");
this.state.fullscreen = mode === "fullscreen";
}
});
useEffect(
@@ -28,19 +29,7 @@ export class ProjectSharingWebClient extends LegacyComponent {
},
() => []
);
}
handleComponentError(error, C) {
// remove the faulty component
this.Components.splice(this.Components.indexOf(C), 1);
/**
* we rethrow the error to notify the user something bad happened.
* We do it after a tick to make sure owl can properly finish its
* rendering
*/
Promise.resolve().then(() => {
throw error;
});
useExternalListener(window, "click", this.onGlobalClick, { capture: true });
}
async _showView() {
@@ -58,7 +47,24 @@ export class ProjectSharingWebClient extends LegacyComponent {
await this.actionService.doAction(open_task_action);
}
}
/**
* @param {MouseEvent} ev
*/
onGlobalClick(ev) {
// When a ctrl-click occurs inside an <a href/> element
// we let the browser do the default behavior and
// we do not want any other listener to execute.
if (
ev.ctrlKey &&
((ev.target instanceof HTMLAnchorElement && ev.target.href) ||
(ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])")))
) {
ev.stopImmediatePropagation();
return;
}
}
}
ProjectSharingWebClient.components = { ActionContainer, ErrorHandler, MainComponentsContainer };
ProjectSharingWebClient.components = { ActionContainer, MainComponentsContainer };
ProjectSharingWebClient.template = 'project.ProjectSharingWebClient';
@@ -1,4 +1,4 @@
.o_legacy_kanban_view.o_kanban_dashboard.o_project_kanban {
.o_kanban_renderer.o_kanban_dashboard.o_project_kanban {
.o_project_kanban_main {
max-width: 300px;
@@ -30,11 +30,6 @@
}
}
.o_form_project_tasks .o_project_task_project_field input::placeholder {
color: darkred;
font-style: italic;
}
.oe_title .o_favorite i.fa {
font-size: inherit;
}
@@ -37,70 +37,6 @@
cursor: pointer;
}
.o_legacy_kanban_view.o_pupdate_kanban {
overflow: auto;
&.o_kanban_ungrouped {
padding:0px;
.o_pupdate_kanban_card {
width: 100%;
margin: 0px;
border-right: 0px;
border-top: 0px;
overflow: hidden;
.o_kanban_detail_ungrouped {
padding-left: $o-horizontal-padding - $o-kanban-color-border-width - $o-kanban-record-margin;
> div {
display: grid;
align-items: center;
}
.o_pupdate_kanban_image {
width: 56px;
height: 56px;
top: 0px;
position: absolute;
}
.o_pupdate_name {
overflow-wrap: break-word;
}
.o_pupdate_kanban_actions_ungrouped {
button {
float: right;
margin: 4px;
}
}
.o_country_flag {
margin-right: 8px;
}
}
}
}
.oe_kanban_color_20 {
border-left-color: $o-success;
&:after {
background-color: $o-success;
}
}
.oe_kanban_color_21 {
border-left-color: $o-info;
&:after {
background-color: $o-info;
}
}
.oe_kanban_color_22 {
border-left-color: $o-warning;
&:after {
background-color: $o-warning;
}
}
.oe_kanban_color_23 {
border-left-color: $o-danger;
&:after {
background-color: $o-danger;
}
}
}
.o_kanban_project_tasks .o_field_many2manytags, .o_kanban_tags{
margin: 0px;
}
@@ -0,0 +1,107 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { ProjectStopRecurrenceConfirmationDialog } from '../components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog';
class ProjectTaskRecurrence {
constructor(env, dialog, orm) {
this.env = env;
this.dialog = dialog;
this.orm = orm;
this.resModel = 'project.task';
}
async stopRecurrence(tasks, callback = () => {}) {
const taskIdsWithRecurrence = [];
const tasksPerRecurrence = {};
const recurrenceIds = [];
for (const task of tasks) {
if (task.data.recurrence_id) {
taskIdsWithRecurrence.push(task.resId);
const recurrenceId = task.data.recurrence_id && task.data.recurrence_id[0];
if (!(recurrenceId in tasksPerRecurrence)) {
tasksPerRecurrence[recurrenceId] = [task.resId];
recurrenceIds.push(recurrenceId);
} else {
tasksPerRecurrence[recurrenceId].push(task.resId);
}
}
}
if (!recurrenceIds.length) {
callback();
return;
}
let allowContinue = false;
if (recurrenceIds.length === 1) {
const count = await this.orm.searchCount(
this.resModel,
[['recurrence_id', '=', recurrenceIds[0]]],
);
allowContinue = count != 1;
} else {
const taskReadGroup = await this.orm.readGroup(
this.resModel,
[['recurrence_id', 'in', recurrenceIds]],
['recurrence_id'],
['recurrence_id'],
);
allowContinue = true;
for (const res of taskReadGroup) {
const taskCount = tasksPerRecurrence[res.recurrence_id[0]].length;
if (taskCount === res.recurrence_id_count) {
allowContinue = false;
break;
}
}
}
let dialogBody;
if (tasks.length > 1) {
dialogBody = allowContinue
? this.env._t('It seems that some tasks are part of a recurrence.')
: this.env._t('It seems that some tasks are part of a recurrence. At least one of them must be kept as a model to create the next occurences.');
} else {
dialogBody = allowContinue
? this.env._t('It seems that this task is part of a recurrence.')
: this.env._t('It seems that this task is recurrent. Would you like to stop its recurrence?');
}
const dialogProps = {
body: dialogBody,
confirm: async () => {
await this.orm.call(
this.resModel,
'action_stop_recurrence',
[taskIdsWithRecurrence],
);
callback();
},
cancel: () => {},
};
if (allowContinue) {
dialogProps.continueRecurrence = async () => {
await this.orm.call(
this.resModel,
'action_continue_recurrence',
[taskIdsWithRecurrence],
);
callback();
};
}
this.dialog.add(ProjectStopRecurrenceConfirmationDialog, dialogProps);
}
}
export const taskRecurrenceService = {
dependencies: ['dialog', 'orm'],
async: [
'stopRecurrence',
],
start(env, { dialog, orm }) {
return new ProjectTaskRecurrence(env, dialog, orm);
}
};
registry.category('services').add('project_task_recurrence', taskRecurrenceService);
@@ -0,0 +1,13 @@
/** @odoo-module */
/**
* List of colors according to the selection value, see `project_update.py`
*/
export const STATUS_COLORS = {
'on_track': 10,
'at_risk': 2,
'off_track': 1,
'on_hold': 4,
};
export const STATUS_COLOR_PREFIX = 'o_status_bubble mx-0 o_color_bubble_';
@@ -0,0 +1,33 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { FormRenderer } from '@web/views/form/form_renderer';
const { useRef, useEffect } = owl;
export class FormRendererWithHtmlExpander extends FormRenderer {
setup() {
super.setup();
this.ui = useService('ui');
const ref = useRef('compiled_view_root');
useEffect(
(el, size) => {
if (el && size === 6) {
const descriptionField = el.querySelector(this.htmlFieldQuerySelector);
if (descriptionField) {
const editor = descriptionField.querySelector('.note-editable');
const elementToResize = editor || descriptionField;
const { bottom, height } = elementToResize.getBoundingClientRect();
const minHeight = document.documentElement.clientHeight - bottom - height;
elementToResize.style.minHeight = `${minHeight}px`;
}
}
},
() => [ref.el, this.ui.size, this.props.record.mode],
);
}
get htmlFieldQuerySelector() {
return '.oe_form_field.oe_form_field_html';
}
}
@@ -0,0 +1,12 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { formView } from '@web/views/form/form_view';
import { FormRendererWithHtmlExpander } from './form_renderer_with_html_expander';
export const formViewWithHtmlExpander = {
...formView,
Renderer: FormRendererWithHtmlExpander,
};
registry.category('views').add('form_description_expander', formViewWithHtmlExpander);
@@ -0,0 +1,9 @@
/** @odoo-module */
import { FormRendererWithHtmlExpander } from "../form_with_html_expander/form_renderer_with_html_expander";
export class ProjectFormRenderer extends FormRendererWithHtmlExpander {
get htmlFieldQuerySelector() {
return '.o_field_html[name="description"]';
}
}
@@ -0,0 +1,12 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { formViewWithHtmlExpander } from '../form_with_html_expander/form_view_with_html_expander';
import { ProjectFormRenderer } from "./project_form_renderer";
export const projectFormView = {
...formViewWithHtmlExpander,
Renderer: ProjectFormRenderer,
};
registry.category("views").add("project_form", projectFormView);
@@ -0,0 +1,45 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { FormController } from '@web/views/form/form_controller';
export class ProjectTaskFormController extends FormController {
setup() {
super.setup();
this.taskRecurrence = useService('project_task_recurrence');
}
getActionMenuItems() {
if (!(this.archiveEnabled && this.model.root.isActive) || !this.model.root.data.recurrence_id) {
return super.getActionMenuItems();
}
this.archiveEnabled = false;
const actionMenuItems = super.getActionMenuItems();
this.archiveEnabled = true;
if (actionMenuItems) {
actionMenuItems.other.unshift({
description: this.env._t('Archive'),
callback: () => this.taskRecurrence.stopRecurrence(
[this.model.root],
() => this.model.root.archive(),
),
});
}
return actionMenuItems;
}
deleteRecord() {
if (!this.model.root.data.recurrence_id) {
return super.deleteRecord();
}
this.taskRecurrence.stopRecurrence(
[this.model.root],
() => {
this.model.root.delete();
if (!this.model.root.resId) {
this.env.config.historyBack();
}
}
);
}
}
@@ -0,0 +1,12 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { formViewWithHtmlExpander } from '../form_with_html_expander/form_view_with_html_expander';
import { ProjectTaskFormController } from './project_task_form_controller';
export const projectTaskFormView = {
...formViewWithHtmlExpander,
Controller: ProjectTaskFormController,
};
registry.category("views").add("project_task_form", projectTaskFormView);
@@ -0,0 +1,27 @@
.o_form_project_tasks {
.o_widget_web_ribbon:not(.o_invisible_modifier) + div div[name="kanban_state"] > div {
margin-right: 150px;
padding-left: 1rem;
}
div[name="kanban_state"] span.o_status {
width: 17px;
height: 17px;
}
.note-editable {
border: 0;
}
.o_wysiwyg_resizer {
border: 0;
}
.o_form_project_recurrence_message *:last-child {
margin-bottom: 0;
}
&.o_form_editable .oe_title {
max-width: initial;
}
}
@@ -0,0 +1,36 @@
/** @odoo-module */
import { KanbanDynamicGroupList } from "@web/views/kanban/kanban_model";
export class ProjectTaskKanbanDynamicGroupList extends KanbanDynamicGroupList {
get context() {
const context = super.context;
if (context.createPersonalStageGroup) {
context.default_user_id = context.uid;
delete context.createPersonalStageGroup;
delete context.default_project_id;
}
return context;
}
get isGroupedByStage() {
return !!this.groupByField && this.groupByField.name === 'stage_id';
}
get isGroupedByPersonalStages() {
return !!this.groupByField && this.groupByField.name === 'personal_stage_type_ids';
}
async createGroup() {
if (this.isGroupedByPersonalStages) {
this.defaultContext = Object.assign({}, this.defaultContext || {}, {
createPersonalStageGroup: true,
});
}
const result = await super.createGroup(...arguments);
if (this.isGroupedByPersonalStages) {
delete this.defaultContext.createPersonalStageGroup;
}
return result;
}
}
@@ -0,0 +1,26 @@
/** @odoo-module */
import { KanbanModel } from "@web/views/kanban/kanban_model";
import { ProjectTaskKanbanDynamicGroupList } from "./project_task_kanban_dynamic_group_list";
import { ProjectTaskRecord } from './project_task_kanban_record';
export class ProjectTaskKanbanGroup extends KanbanModel.Group {
get isPersonalStageGroup() {
return !!this.groupByField && this.groupByField.name === 'personal_stage_type_ids';
}
async delete() {
if (this.isPersonalStageGroup) {
return await this.model.orm.call(this.resModel, 'remove_personal_stage', [this.resId]);
} else {
return super.delete();
}
}
}
export class ProjectTaskKanbanModel extends KanbanModel { }
ProjectTaskKanbanModel.DynamicGroupList = ProjectTaskKanbanDynamicGroupList;
ProjectTaskKanbanModel.Group = ProjectTaskKanbanGroup;
ProjectTaskKanbanModel.Record = ProjectTaskRecord;
@@ -0,0 +1,14 @@
/* @odoo-module */
import { Record } from '@web/views/relational_model';
export class ProjectTaskRecord extends Record {
async _applyChanges(changes) {
const value = changes.personal_stage_type_ids;
if (value && Array.isArray(value) && value.length === 1) {
delete changes.personal_stage_type_ids;
changes.personal_stage_type_id = value;
}
await super._applyChanges(changes);
}
}
@@ -0,0 +1,91 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { KanbanRenderer } from '@web/views/kanban/kanban_renderer';
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
const { onWillStart } = owl;
export class ProjectTaskKanbanRenderer extends KanbanRenderer {
setup() {
super.setup();
this.userService = useService('user');
this.action = useService('action');
this.isProjectManager = false;
onWillStart(this.onWillStart);
}
get canMoveRecords() {
let canMoveRecords = super.canMoveRecords;
if (!canMoveRecords && this.canResequenceRecords && this.props.list.isGroupedByPersonalStages) {
const { groupByField } = this.props.list;
const { modifiers } = groupByField;
canMoveRecords = !(modifiers && modifiers.readonly);
}
return canMoveRecords;
}
get canResequenceGroups() {
let canResequenceGroups = super.canResequenceGroups;
if (!canResequenceGroups && this.props.list.isGroupedByPersonalStages) {
const { modifiers } = this.props.list.groupByField;
const { groupsDraggable } = this.props.archInfo;
canResequenceGroups = groupsDraggable && !(modifiers && modifiers.readonly);
}
return canResequenceGroups;
}
async onWillStart() {
if (!this.props.list.isGroupedByPersonalStages) { // no need to check it if the group by is personal stages
this.isProjectManager = await this.userService.hasGroup('project.group_project_manager');
}
}
canCreateGroup() {
return super.canCreateGroup() && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages;
}
canDeleteGroup(group) {
return super.canDeleteGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages;
}
canEditGroup(group) {
return super.canEditGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages;
}
async deleteGroup(group) {
if (group && group.groupByField.name === 'stage_id') {
const action = await group.model.orm.call(
group.resModel,
'unlink_wizard',
[group.resId],
{ context: group.context },
);
this.action.doAction(action);
return;
}
super.deleteGroup(group);
}
editGroup(group) {
const groupBy = this.props.list.groupBy;
if (groupBy.length !== 1 || groupBy[0] !== 'personal_stage_type_ids') {
super.editGroup(group);
return;
}
const context = Object.assign({}, group.context, {
form_view_ref: 'project.personal_task_type_edit',
});
this.dialog.add(FormViewDialog, {
context,
resId: group.value,
resModel: group.resModel,
title: this.env._t('Edit Personal Stage'),
onRecordSaved: async () => {
await this.props.list.load();
this.props.list.model.notify();
},
});
}
}
@@ -0,0 +1,16 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { kanbanView } from '@web/views/kanban/kanban_view';
import { ProjectTaskKanbanModel } from "./project_task_kanban_model";
import { ProjectTaskKanbanRenderer } from './project_task_kanban_renderer';
import { ProjectControlPanel } from "../../components/project_control_panel/project_control_panel";
export const projectTaskKanbanView = {
...kanbanView,
Model: ProjectTaskKanbanModel,
Renderer: ProjectTaskKanbanRenderer,
ControlPanel: ProjectControlPanel,
};
registry.category('views').add('project_task_kanban', projectTaskKanbanView);
@@ -0,0 +1,5 @@
.o_kanban_project_tasks {
div.o_field_many2many_avatar_user[name="user_ids"] {
margin-left: 6px;
}
}
@@ -0,0 +1,58 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { ListController } from '@web/views/list/list_controller';
export class ProjectTaskListController extends ListController {
setup() {
super.setup();
this.taskRecurrence = useService('project_task_recurrence');
}
getActionMenuItems() {
if (!this.archiveEnabled || this.model.root.isM2MGrouped) {
return super.getActionMenuItems();
}
const hasAnyRecurrences = this._anySelectedTasksWithRecurrence();
this.archiveEnabled = !hasAnyRecurrences;
const actionMenuItems = super.getActionMenuItems();
this.archiveEnabled = true;
if (actionMenuItems && hasAnyRecurrences) {
actionMenuItems.other.splice(
this.isExportEnable ? 1 : 0,
0,
{
description: this.env._t('Archive'),
callback: () => this.taskRecurrence.stopRecurrence(
this.model.root.selection,
() => this.toggleArchiveState(true),
),
},
{
description: this.env._t('Unarchive'),
callback: () => this.toggleArchiveState(false),
},
);
}
return actionMenuItems;
}
onDeleteSelectedRecords() {
if (this._anySelectedTasksWithRecurrence()) {
return this.taskRecurrence.stopRecurrence(
this.model.root.selection,
() => this.model.root.deleteRecords(),
);
}
return super.onDeleteSelectedRecords();
}
_anySelectedTasksWithRecurrence() {
for (const selectedTask of this.model.root.selection) {
if (selectedTask.data.recurrence_id) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,14 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { listView } from '@web/views/list/list_view';
import { ProjectControlPanel } from "../../components/project_control_panel/project_control_panel";
import { ProjectTaskListController } from './project_task_list_controller';
export const projectTaskListView = {
...listView,
Controller: ProjectTaskListController,
ControlPanel: ProjectControlPanel,
};
registry.category("views").add("project_task_list", projectTaskListView);
@@ -0,0 +1,75 @@
.o_pupdate_kanban {
overflow: auto;
&.o_kanban_ungrouped {
padding:0px;
.o_pupdate_kanban_card {
width: 100%;
margin: 0px;
border-right: 0px;
border-top: 0px;
overflow: hidden;
.o_kanban_detail_ungrouped {
padding-left: $o-horizontal-padding - $o-kanban-color-border-width - $o-kanban-record-margin;
> div {
display: grid;
align-items: center;
}
.o_pupdate_kanban_image {
width: 56px;
height: 56px;
top: 0px;
position: absolute;
}
.o_pupdate_name {
overflow-wrap: break-word;
}
.o_pupdate_kanban_actions_ungrouped {
button {
float: right;
margin: 4px;
}
}
.o_country_flag {
margin-right: 8px;
}
.o_field_status_with_color {
.o_status_label {
display: none;
}
@include media-breakpoint-up(sm) {
.o_status_label {
display: block;
}
}
}
}
}
}
.oe_kanban_color_20 {
border-left-color: $o-success;
&:after {
background-color: $o-success;
}
}
.oe_kanban_color_21 {
border-left-color: $o-info;
&:after {
background-color: $o-info;
}
}
.oe_kanban_color_22 {
border-left-color: $o-warning;
&:after {
background-color: $o-warning;
}
}
.oe_kanban_color_23 {
border-left-color: $o-danger;
&:after {
background-color: $o-danger;
}
}
}
@@ -0,0 +1,16 @@
/** @odoo-module */
import { KanbanController } from '@web/views/kanban/kanban_controller';
import { ProjectRightSidePanel } from '../../components/project_right_side_panel/project_right_side_panel';
export class ProjectUpdateKanbanController extends KanbanController {
get className() {
return super.className + ' o_controller_with_rightpanel';
}
}
ProjectUpdateKanbanController.components = {
...KanbanController.components,
ProjectRightSidePanel,
};
ProjectUpdateKanbanController.template = 'project.ProjectUpdateKanbanView';
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project.ProjectUpdateKanbanView" t-inherit="web.KanbanView" t-inherit-mode="primary" owl="1">
<xpath expr="//Layout" position="inside">
<ProjectRightSidePanel
context="props.context"
domain="props.domain"
/>
</xpath>
</t>
</templates>
@@ -0,0 +1,12 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { ProjectUpdateKanbanController } from './project_update_kanban_controller';
export const projectUpdateKanbanView = {
...kanbanView,
Controller: ProjectUpdateKanbanController,
};
registry.category('views').add('project_update_kanban', projectUpdateKanbanView);
@@ -0,0 +1,16 @@
/** @odoo-module */
import { ListController } from '@web/views/list/list_controller';
import { ProjectRightSidePanel } from '../../components/project_right_side_panel/project_right_side_panel';
export class ProjectUpdateListController extends ListController {
get className() {
return super.className + ' o_controller_with_rightpanel';
}
}
ProjectUpdateListController.components = {
...ListController.components,
ProjectRightSidePanel,
};
ProjectUpdateListController.template = 'project.ProjectUpdateListView';
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project.ProjectUpdateListView" t-inherit="web.ListView" t-inherit-mode="primary" owl="1">
<xpath expr="//Layout" position="inside">
<ProjectRightSidePanel
context="props.context"
domain="props.domain"
/>
</xpath>
</t>
</templates>
@@ -0,0 +1,12 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { ProjectUpdateListController } from './project_update_list_controller';
export const projectUpdateListView = {
...listView,
Controller: ProjectUpdateListController,
};
registry.category('views').add('project_update_list', projectUpdateListView);
@@ -26,223 +26,6 @@
</xpath>
</div>
<t t-name="project.ProjectRightPanel" owl="1">
<div t-if="project_id" class="o_rightpanel">
<t t-call="project.StatButtonsSection"/>
<t t-call="project.ProfitabilitySection"/>
<t t-call="project.MilestoneSection"/>
</div>
<!-- If this is called from notif, multiples updates but no specific project -->
<div t-else=""/>
</t>
<t t-name="project.StatButtonsSection" owl="1">
<div class="o_rightpanel_section">
<div class="o_form_view">
<div class="oe_button_box o-form-buttonbox">
<t t-foreach="state.data.buttons" t-as="button" t-key="button.action">
<button class="btn oe_stat_button" t-if="button.show"
t-on-click="onProjectActionClick"
t-att-data-action="button.action"
t-att-data-additional_context="button.additional_context"
t-att-data-type="button.action_type">
<i t-att-class="'fa fa-fw o_button_icon fa-' + button.icon"/>
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<t t-esc="button.number"/>
</span>
<span class="o_stat_text">
<t t-esc="button.text"/>
</span>
</div>
</button>
</t>
</div>
</div>
</div>
</t>
<t t-name="project.ProfitabilitySection" owl="1">
<t t-set="profitability_items" t-value="state.data.profitability_items"/>
<t t-set="profitability_labels" t-value="state.data.profitability_labels"/>
<div name="profitability" class="o_rightpanel_section" t-if="profitability_items and (profitability_items.revenues.data.length or profitability_items.costs.data.length)">
<div class="o_rightpanel_header">
<div class="o_rightpanel_title">
Profitability
</div>
</div>
<div class="o_rightpanel_data">
<div class="o_rightpanel_subsection" t-if="profitability_items.revenues.data.length">
<t t-call="project.RevenueSection"/>
</div>
<div class="o_rightpanel_subsection" t-if="profitability_items.costs.data.length">
<t t-call="project.CostSection"/>
</div>
<div class="o_rightpanel_subsection">
<t t-call="project.MarginSection"/>
</div>
</div>
</div>
</t>
<t t-name="project.RevenueSection" owl="1">
<t t-set="revenues" t-value="profitability_items.revenues"/>
<t t-set="revenue_items" t-value="revenues.data"/>
<t t-set="revenue_total" t-value="revenues.total"/>
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Revenues</th>
<th style="width: 20%; text-align: right;">Invoiced</th>
<th style="width: 20%; text-align: right;">To Invoice</th>
<th style="width: 20%; text-align: right;">Expected</th>
</tr>
</thead>
<tbody>
<tr t-foreach="revenue_items" t-as="revenue" t-key="revenue.id" t-if="revenue.invoiced !== 0 || revenue.to_invoice !== 0">
<t t-set="revenue_label" t-value="profitability_labels[revenue.id] or revenue.id"/>
<td>
<a t-if="revenue.action" class="o_rightpanel_button"
t-on-click="onProjectActionClick"
t-att-data-action="revenue.action.name"
t-att-data-type="revenue.action.type or 'action'"
t-att-data-domain="revenue.action.domain"
t-att-data-section="revenue.action.section"
t-att-data-res-id="revenue.action.res_id"
t-att-data-context="revenue.action.context"
>
<t t-esc="revenue_label"/>
</a>
<t t-esc="revenue_label" t-else=""/>
</td>
<td align="right" t-attf-class="{{ revenue.invoiced === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(revenue.invoiced)"/></td>
<td align="right" t-attf-class="{{ revenue.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(revenue.to_invoice)"/></td>
<td align="right" t-attf-class="{{ revenue.invoiced + revenue.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(revenue.invoiced + revenue.to_invoice)"/></td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td align="right" t-attf-class="{{ revenue_total.invoiced === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(revenue_total.invoiced)"/></td>
<td align="right" t-attf-class="{{ revenue_total.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(revenue_total.to_invoice)"/></td>
<td align="right" t-attf-class="{{ revenue_total.invoiced + revenue_total.to_invoice === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(revenue_total.invoiced + revenue_total.to_invoice)"/></td>
</tr>
</tfoot>
</table>
</t>
<t t-name="project.CostSection" owl="1">
<t t-set="costs" t-value="profitability_items.costs"/>
<t t-set="cost_items" t-value="costs.data"/>
<t t-set="cost_total" t-value="costs.total"/>
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Costs</th>
<th style="width: 20%; text-align: right;">Billed</th>
<th style="width: 20%; text-align: right;">To Bill</th>
<th style="width: 20%; text-align: right;">Expected</th>
</tr>
</thead>
<tbody>
<tr t-foreach="cost_items" t-as="cost" t-key="cost.id" t-if="cost.billed !== 0 || cost.to_bill !== 0">
<t t-set="cost_label" t-value="profitability_labels[cost.id] or cost.id"/>
<td>
<a t-if="cost.action" class="o_rightpanel_button"
t-on-click="onProjectActionClick"
t-att-data-action="cost.action.name"
t-att-data-type="cost.action.type or 'action'"
t-att-data-domain="cost.action.domain"
t-att-data-section="cost.action.section"
t-att-data-res-id="cost.action.res_id"
t-att-data-context="cost.action.context"
>
<t t-esc="cost_label"/>
</a>
<t t-esc="cost_label" t-else=""/>
</td>
<td align="right" t-attf-class="{{ cost.billed === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(cost.billed)"/></td>
<td align="right" t-attf-class="{{ cost.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(cost.to_bill)"/></td>
<td align="right" t-attf-class="{{ cost.billed + cost.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(cost.billed + cost.to_bill)"/></td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td align="right" t-attf-class="{{ cost_total.billed === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(cost_total.billed)"/></td>
<td align="right" t-attf-class="{{ cost_total.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(cost_total.to_bill)"/></td>
<td align="right" t-attf-class="{{ cost_total.billed + cost_total.to_bill === 0 ? 'text-muted' : ''}}"><t t-esc="formatMonetary(cost_total.billed + cost_total.to_bill)"/></td>
</tr>
</tfoot>
</table>
</t>
<t t-name="project.MarginSection" owl="1">
<t t-set="revenues_total" t-value="profitability_items.revenues.total"/>
<t t-set="costs_total" t-value="profitability_items.costs.total"/>
<t t-set="margin_invoiced_billed" t-value="revenues_total.invoiced + costs_total.billed"/>
<t t-set="margin_to_invoice_to_bill" t-value="revenues_total.to_invoice + costs_total.to_bill"/>
<t t-set="margin_total" t-value="margin_invoiced_billed + margin_to_invoice_to_bill"/>
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Margin</th>
<th style="width: 20%; text-align: right;" t-att-class="margin_invoiced_billed &lt; 0 ? 'o_color_red' : 'o_color_green'"><t t-esc="formatMonetary(margin_invoiced_billed)"/></th>
<th style="width: 20%; text-align: right;" t-att-class="margin_to_invoice_to_bill &lt; 0 ? 'o_color_red' : 'o_color_green'"><t t-esc="formatMonetary(margin_to_invoice_to_bill)"/></th>
<th style="width: 20%; text-align: right;" t-att-class="margin_total &lt; 0 ? 'o_color_red' : 'o_color_green'"><t t-esc="formatMonetary(margin_total)"/></th>
</tr>
</thead>
</table>
</t>
<t t-name="project.MilestoneSection" owl="1">
<t t-set="show_milestones" t-value="state.data.hasOwnProperty('milestones')"/>
<t t-set="milestones" t-value="state.data.milestones"/>
<div class="o_rightpanel_section" name="milestone" t-if="show_milestones">
<div class="o_rightpanel_header">
<div class="o_rightpanel_title cursor-default">
Milestones
</div>
<span class="o_rightpanel_button">
<AddMilestone context="context" onMilestoneUpdate.bind="_loadQwebContext"/>
</span>
</div>
<div class="o_rightpanel_data">
<div t-foreach="milestones.data" t-as="milestone" t-key="milestone.id" class="o_rightpanel_data_row">
<OpenMilestone context="context" milestone="milestone" onMilestoneUpdate.bind="_loadQwebContext"/>
</div>
<span t-if="milestones.data.length === 0" class="text-muted fst-italic">
Track major progress points that must be reached to achieve success.
</span>
</div>
</div>
</t>
<t t-name="project.AddMilestone" owl="1">
<div class="o_add_milestone">
<a t-on-click="onAddMilestoneClick">Add Milestone</a>
</div>
</t>
<t t-name="project.OpenMilestone" owl="1">
<div class="o_open_milestone" t-att-class="state.colorClass">
<span class="o_milestone_checkbox" t-on-click="onMilestoneClick">
<i class="fa" t-att-class="state.checkboxIcon"></i>
</span>
<div class="o_milestone_detail" t-on-click="onOpenMilestone">
<div class="o_milestone_info">
<t t-esc="milestone.name"/>
</div>
<span class="o_rightpanel_center_col">
<t t-esc="deadline"/>
</span>
</div>
<span class="o_rightpanel_right_col">
<a class="o_delete_icon" t-on-click="onDeleteMilestone" title="Delete Milestone"><i class="fa fa-trash"></i></a>
</span>
</div>
</t>
<t t-name="project.task.PrivateProjectName">
<span class="fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</t>
@@ -1,43 +1,33 @@
/** @odoo-module */
import { createView } from 'web.test_utils';
import {ProjectFormView} from "@project/js/project_form";
import {patch} from "@web/core/utils/patch";
import core from "web.core";
let serverData = null;
const ProjectFormController = ProjectFormView.prototype.config.Controller;
QUnit.module("Project", hooks => {
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let makeViewParams, target;
QUnit.module("Project", (hooks) => {
hooks.beforeEach(() => {
serverData = {
project: {
fields: {
id: { string: "Id", type: "integer" },
makeViewParams = {
type: "form",
resModel: "project.project",
serverData: {
models: {
"project.project": {
fields: {
id: { string: "Id", type: "integer" },
},
records: [{ id: 1, display_name: "First record" }],
},
},
records: [
{ id: 1, display_name: "First record"},
],
},
}
})
arch: `<form js_class="project_form"><field name="display_name"/></form>`,
};
target = getFixture();
setupViewRegistries();
});
QUnit.module("Form");
QUnit.test("project form view", async function (assert) {
/*
This is a test whitebox because the flow cannot be reproduced correctly otherwise.
The idea is to check that the DOM_updated event is not triggered twice to avoid a crash.
*/
patch(ProjectFormController.prototype, "patchedInit", {
init: function () {
this._super(...arguments);
core.bus.trigger("DOM_updated");
}
});
const form = await createView({
View: ProjectFormView,
model: 'project',
data: serverData,
arch: '<form><field name="display_name"/></form>',
});
assert.containsOnce(document.body, form.el);
form.destroy()
})
await makeView(makeViewParams);
assert.containsOnce(target, ".o_form_view");
});
});
@@ -52,7 +52,7 @@ tour.register('personal_stage_tour', {
trigger: ".dropdown-item.o_column_edit",
}, {
content: "Change title",
trigger: 'input.o_field_char[name="name"]',
trigger: 'div.o_field_char[name="name"] input',
run: 'text (Todo)',
}, {
content: "Save changes",
@@ -3,13 +3,7 @@
import tour from 'web_tour.tour';
const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project App.'), {
// an invisible element cannot be used as a trigger so this small hack is mandatory for the next step
trigger: '.o_kanban_record:contains("Project Sharing")',
run: function () {
this.$anchor.find('.o_dropdown_kanban').css('visibility', 'visible');
},
}, {
trigger: '.oe_kanban_global_click :contains("Project Sharing") .o_dropdown_kanban',
trigger: '.oe_kanban_global_click :contains("Project Sharing") button.o_dropdown_kanban',
content: 'Open the project dropdown.'
}, {
trigger: '.o_kanban_record:contains("Project Sharing") .dropdown-menu a:contains("Share")',
@@ -24,7 +18,7 @@ const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_p
actions.text('Georges', this.$anchor.find('input'));
},
}, {
trigger: '.ui-autocomplete a:contains("Georges")',
trigger: '.ui-autocomplete a.dropdown-item:contains("Georges")',
in_modal: false,
}, {
trigger: 'footer > button[name="action_send_mail"]',
@@ -21,7 +21,7 @@ function openProjectUpdateAndReturnToTasks(view, viewClass) {
}, {
trigger: '.o_back_button',
content: 'Go back to the task view : ' + view,
extra_trigger: '.o_list_view, .o_legacy_list_view',
// extra_trigger: '.o_list_view, .o_legacy_list_view', // FIXME: [XBO] uncomment it when the sample data will be displayed after discarding the creation of a project update record.
},
];
}
@@ -65,7 +65,7 @@ tour.register('project_update_tour', {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_kanban_group:eq(0)'
}, {
trigger: '.o_kanban_quick_create input.o_field_char[name=name]',
trigger: '.o_kanban_quick_create div.o_field_char[name=name] input',
extra_trigger: '.o_kanban_project_tasks',
run: 'text New task'
}, {
@@ -75,7 +75,7 @@ tour.register('project_update_tour', {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_kanban_group:eq(0)'
}, {
trigger: '.o_kanban_quick_create input.o_field_char[name=name]',
trigger: '.o_kanban_quick_create div.o_field_char[name=name] input',
extra_trigger: '.o_kanban_project_tasks',
run: 'text Second task'
}, {
@@ -84,7 +84,7 @@ tour.register('project_update_tour', {
}, {
trigger: '.o_kanban_group:nth-child(2) .o_kanban_header',
run: function () {
document.querySelector('.o_kanban_group:nth-child(2) .o_kanban_config.dropdown .dropdown-toggle').dispatchEvent(new Event('click'))
document.querySelector('.o_kanban_group:nth-child(2) .o_kanban_config.dropdown .dropdown-toggle').dispatchEvent(new Event('click'));
}
}, {
trigger: ".dropdown-item.o_column_edit",
@@ -103,58 +103,52 @@ tour.register('project_update_tour', {
trigger: ".o_add_milestone a",
content: "Add a first milestone"
}, {
trigger: "input.o_field_widget[name=name]",
trigger: "div.o_field_widget[name=name] input",
run: 'text New milestone'
}, {
trigger: "input.datetimepicker-input[name=deadline]",
trigger: "div[name=deadline] .datetimepicker-input",
run: 'text 12/12/2099'
}, {
trigger: ".modal-footer button"
trigger: ".modal-footer .o_form_button_save"
}, {
trigger: ".o_add_milestone a",
}, {
trigger: "input.o_field_widget[name=name]",
trigger: "div.o_field_widget[name=name] input",
run: 'text Second milestone'
}, {
trigger: "input.datetimepicker-input[name=deadline]",
trigger: "div[name=deadline] .datetimepicker-input",
run: 'text 12/12/2022'
}, {
trigger: ".modal-footer button"
trigger: ".modal-footer .o_form_button_save"
}, {
trigger: ".o_open_milestone:eq(1) .o_milestone_detail span:eq(0)",
extra_trigger: ".o_add_milestone a",
run: function () {
setTimeout(() => {
this.$anchor.click();
}, 500);
},
trigger: ".o_rightpanel_milestone:eq(1) .o_milestone_detail",
}, {
trigger: "input.datetimepicker-input[name=deadline]",
trigger: "div[name=deadline] .datetimepicker-input",
run: 'text 12/12/2100'
}, {
trigger: ".modal-footer button"
trigger: ".modal-footer .o_form_button_save"
}, {
trigger: ".o-kanban-button-new",
content: "Create a new update"
}, {
trigger: "input.o_field_widget[name=name]",
trigger: "div.o_field_widget[name=name] input",
run: 'text New update'
}, {
trigger: ".o_form_button_save"
}, {
trigger: ".o_field_widget[name=description] h1:contains('Activities')",
trigger: ".o_field_widget[name='description'] h1:contains('Activities')",
run: function () {},
}, {
trigger: ".o_field_widget[name=description] h3:contains('Milestones')",
trigger: ".o_field_widget[name='description'] h3:contains('Milestones')",
run: function () {},
}, {
trigger: ".o_field_widget[name=description] div[name='milestone'] ul li:contains('(12/12/2099 => 12/12/2100)')",
trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li:contains('(12/12/2099 => 12/12/2100)')",
run: function () {},
}, {
trigger: ".o_field_widget[name=description] div[name='milestone'] ul li span:contains('(due 12/12/2022)')",
trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li span:contains('(due 12/12/2022)')",
run: function () {},
}, {
trigger: ".o_field_widget[name=description] div[name='milestone'] ul li span:contains('(due 12/12/2100)')",
trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li span:contains('(due 12/12/2100)')",
run: function () {},
}, {
trigger: '.o_back_button',
+11 -12
View File
@@ -36,7 +36,7 @@
<field name="project_id" invisible="1"/>
<field name="color" invisible="1"/>
<field name="status" widget="status_with_color" options="{'color_field': 'color'}"/>
<field name="progress" widget="progressbar" options="{'current_value': 'progress', 'max_value': '100', 'editable': true}"/>
<field name="progress" widget="progressbar" options="{'editable': true}"/>
</group>
<group>
<field name="user_id" widget="many2one_avatar_user" readonly="1"/>
@@ -70,26 +70,25 @@
<div t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + record.color.raw_value : ''}} oe_kanban_global_click o_pupdate_kanban_card">
<!-- Project Update Kanban View is always ungrouped - see js_class -->
<div class="o_kanban_detail_ungrouped row">
<div class="col-lg-4 o_pupdate_name">
<div class="col-sm-4 col-6 o_pupdate_name">
<b><field name="name_cropped"/></b>
<field name="color" invisible="1"/>
<field name="status" widget="status_with_color" options="{'color_field': 'color'}"/>
</div>
<div class="col-lg-2">
<div>
<field name="user_id" widget="many2one_avatar_user"/>
<strong><t t-esc="record.user_id.value"/></strong>
<div>Author</div>
<t t-esc="record.user_id.value"/>
</div>
</div>
<div class="col-lg-2">
<b><field name="date"/></b>
<div>Date</div>
<div class="col-sm-2 text-sm-start col-6 text-end align-end">
<field name="color" invisible="1"/>
<b><field name="status" widget="status_with_color" options="{'color_field': 'color'}"/></b>
</div>
<div class="col-lg-2">
<div class="col-sm-2 col-6">
<b><field name="progress_percentage" widget="percentage"/></b>
<div>Progress</div>
</div>
<div class="col-sm-2 col-6">
<b><field name="date"/></b>
<div>Date</div>
</div>
</div>
</div>
</t>
+37 -31
View File
@@ -433,7 +433,7 @@
<field name="name">project.project.form</field>
<field name="model">project.project</field>
<field name="arch" type="xml">
<form string="Project" class="o_form_project_project" js_class="form_description_expander">
<form string="Project" class="o_form_project_project" js_class="project_form">
<field name="company_id" invisible="1"/>
<field name="analytic_account_id" invisible="1"/>
<header>
@@ -493,7 +493,7 @@
<button class="oe_stat_button o_project_not_clickable" disabled="disabled" groups="!project.group_project_manager">
<div class="ps-4">
<field name="last_update_color" invisible="1"/>
<field name="last_update_status" readonly="1" widget="status_with_color" options="{'color_field': 'last_update_color', 'no_quick_edit': 1}"/>
<field name="last_update_status" readonly="1" widget="status_with_color" options="{'color_field': 'last_update_color'}"/>
</div>
</button>
<button name="%(action_project_task_burndown_chart_report)d" type="action" class="oe_stat_button" icon="fa-area-chart" groups="project.group_project_user">
@@ -800,7 +800,12 @@
<field name="name">project.project.kanban</field>
<field name="model">project.project</field>
<field name="arch" type="xml">
<kanban class="oe_background_grey o_kanban_dashboard o_project_kanban o_emphasize_colors" on_create="project.open_create_project" js_class="project_project_kanban" sample="1">
<kanban
class="oe_background_grey o_kanban_dashboard o_project_kanban o_emphasize_colors"
on_create="project.open_create_project"
action="action_view_tasks" type="object"
sample="1"
>
<field name="display_name"/>
<field name="partner_id"/>
<field name="commercial_partner_id"/>
@@ -910,7 +915,7 @@
<a class="o_project_kanban_box" name="action_view_tasks" type="object">
<div>
<span class="o_value"><t t-esc="record.task_count.value"/></span>
<span class="o_label"><t t-esc="record.label_tasks.value"/></span>
<span class="o_label ms-1"><t t-esc="record.label_tasks.value"/></span>
</div>
</a>
<a groups='project.group_project_milestone' t-if="record.allow_milestones and record.allow_milestones.raw_value and record.milestone_count.value &gt; 0"
@@ -926,7 +931,7 @@
<field name="activity_ids" widget="kanban_activity"/>
</div>
<div class="oe_kanban_bottom_right">
<field t-if="record.last_update_status.value &amp;&amp; widget.editable" name="last_update_status" widget="project_state_selection"/>
<field t-if="record.last_update_status.value &amp;&amp; widget.editable" name="last_update_status" widget="project_state_selection" options="{'color_field': 'last_update_color', 'hide_label': 1}"/>
<span t-if="record.last_update_status.value &amp;&amp; !widget.editable" t-att-class="'o_status_bubble mx-0 o_color_bubble_' + record.last_update_color.value" t-att-title="record.last_update_status.value"></span>
<field name="user_id" widget="many2one_avatar_user" t-if="record.user_id.raw_value"/>
</div>
@@ -1059,7 +1064,7 @@
<field name="model">project.task</field>
<field eval="2" name="priority"/>
<field name="arch" type="xml">
<form string="Task" class="o_form_project_tasks" js_class="project_form">
<form string="Task" class="o_form_project_tasks" js_class="project_task_form">
<field name="allow_subtasks" invisible="1" />
<field name="is_closed" invisible="1" />
<field name="allow_recurring_tasks" invisible="1" />
@@ -1137,21 +1142,22 @@
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title pe-0">
<h1 class="d-flex flex-row justify-content-between">
<field name="priority" widget="priority" class="me-3"/>
<field name="name" class="o_task_name text-truncate" placeholder="Task Title..."/>
<field name="kanban_state" widget="state_selection" class="ms-auto"/>
<h1 class="d-flex justify-content-between align-items-center">
<div class="d-flex">
<field name="priority" widget="priority" class="me-3"/>
<field name="name" class="o_task_name text-truncate" placeholder="Task Title..."/>
</div>
<field name="kanban_state" widget="state_selection" class=""/>
</h1>
</div>
<group>
<group>
<label for="project_id" attrs="{'invisible': [('parent_id', '!=', False)]}" class="oe_read_only"/>
<div colspan="1" class="oe_read_only">
<i class="o_project_task_project_field text-danger" attrs="{'invisible': [('project_id', '!=', False)]}">Private</i>
<field name="project_id" attrs="{'invisible': ['|', ('parent_id', '!=', False), ('project_id', '=', False)]}" nolabel="1"/>
</div>
<field name="project_id" attrs="{'invisible': [('parent_id', '!=', False)]}" domain="[('active', '=', True), ('company_id', '=', company_id)]"
placeholder="Private" class="o_project_task_project_field oe_edit_only"/>
<field name="project_id"
placeholder="Private"
domain="[('active', '=', True), ('company_id', '=', company_id)]"
attrs="{'invisible': [('parent_id', '!=', False)]}"
widget="project_private_task"
/>
<field name="display_project_id" string="Project" attrs="{'invisible': [('parent_id', '=', False)]}" domain="[('active', '=', True), ('company_id', '=', company_id)]"/>
<field name="milestone_id"
placeholder="e.g. Product Launch"
@@ -1373,8 +1379,14 @@
<field name="name">project.task.kanban</field>
<field name="model">project.task</field>
<field name="arch" type="xml">
<kanban default_group_by="stage_id" class="o_kanban_small_column o_kanban_project_tasks" on_create="quick_create"
quick_create_view="project.quick_create_task_form" examples="project" js_class="project_task_kanban" sample="1">
<kanban
default_group_by="stage_id"
class="o_kanban_small_column o_kanban_project_tasks"
on_create="quick_create"
quick_create_view="project.quick_create_task_form"
examples="project"
js_class="project_task_kanban" sample="1"
>
<field name="color"/>
<field name="priority"/>
<field name="stage_id" options='{"group_by_tooltip": {"description": "Description"}}'/>
@@ -1409,17 +1421,11 @@
<s t-if="!record.active.raw_value"><field name="name" widget="name_with_subtask_count"/></s>
<t t-else=""><field name="name" widget="name_with_subtask_count"/></t>
</strong>
<t t-if="!record.is_private.raw_value">
<span invisible="context.get('default_project_id', False)"><br/><field name="project_id" required="1"/></span>
<span t-if="record.allow_milestones.raw_value and record.milestone_id.raw_value" t-attf-class="{{record.has_late_and_unreached_milestone.raw_value ? 'text-danger' : ''}}">
<br/>
<field name="milestone_id" />
</span>
</t>
<t t-else="">
<span invisible="context.get('default_project_id', False)"><br/><field name="project_id" widget="project_private_task" options="{'no_open': True}"/></span>
<span t-if="record.allow_milestones.raw_value and record.milestone_id.raw_value" t-attf-class="{{record.has_late_and_unreached_milestone.raw_value ? 'text-danger' : ''}}">
<br/>
<span class="fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</t>
<field name="milestone_id" options="{'no_open': True}" />
</span>
<br />
<t t-if="record.partner_id.value">
<span t-if="!record.partner_is_company.raw_value">
@@ -1482,7 +1488,7 @@
<field name="model">project.task</field>
<field eval="2" name="priority"/>
<field name="arch" type="xml">
<tree string="Tasks" multi_edit="1" sample="1" js_class="project_list">
<tree string="Tasks" multi_edit="1" sample="1" js_class="project_task_list">
<field name="company_id" invisible="1"/>
<field name="message_needaction" invisible="1" readonly="1"/>
<field name="is_closed" invisible="1" />
@@ -1835,7 +1841,7 @@
</xpath>
<xpath expr="//div[hasclass('o_kanban_manage_view')]" position="inside">
<div role="menuitem" groups="project.group_project_user">
<a name="project_update_all_action" type="object" t-attf-context="{'active_id': #{record.id.raw_value}}">Project Updates</a>
<a name="project_update_all_action" type="object" t-attf-context="{'active_id': #{record.id.raw_value} }">Project Updates</a>
</div>
</xpath>
</field>
+3 -2
View File
@@ -90,9 +90,10 @@ class Project(models.Model):
'costs': {'id': section_id, 'sequence': self._get_profitability_sequence_per_invoice_type()[section_id], 'billed': -expense_data['untaxed_amount'], 'to_bill': 0.0},
}
if can_see_expense:
action = {'name': 'action_profitability_items', 'type': 'object', 'section': section_id, 'domain': json.dumps([('id', 'in', expense_data['ids'])])}
args = [section_id, [('id', 'in', expense_data['ids'])]]
if expense_data['ids']:
action['res_id'] = expense_data['ids']
args.append(expense_data['ids'])
action = {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)}
expense_profitability_items['action'] = action
return expense_profitability_items
+3 -2
View File
@@ -132,9 +132,10 @@ class Project(models.Model):
section_id = 'purchase_order'
purchase_order_costs = {'id': section_id, 'sequence': self._get_profitability_sequence_per_invoice_type()[section_id], 'billed': amount_invoiced, 'to_bill': amount_to_invoice}
if with_action and purchase_order_line_ids and self.user_has_groups('purchase.group_purchase_user'):
action = {'name': 'action_profitability_items', 'type': 'object', 'section': section_id, 'domain': json.dumps([('id', 'in', purchase_order_line_ids)])}
args = [section_id, [('id', 'in', purchase_order_line_ids)]]
if len(purchase_order_line_ids) == 1:
action['res_id'] = purchase_order_line_ids[0]
args.append(purchase_order_line_ids[0])
action = {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)}
purchase_order_costs['action'] = action
costs['data'].append(purchase_order_costs)
costs['total']['billed'] += amount_invoiced
@@ -72,10 +72,10 @@ class Project(models.Model):
}
if can_see_expense:
def get_action(res_ids):
action = {'name': 'action_profitability_items', 'type': 'object', 'section': section_id, 'domain': json.dumps([('id', 'in', res_ids)])}
args = [section_id, [('id', 'in', res_ids)]]
if len(res_ids) == 1:
action['res_id'] = res_ids[0]
return action
args.append(res_ids[0])
return {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)}
if reinvoice_expense_ids:
expense_data['revenues']['action'] = get_action(reinvoice_expense_ids)
+2 -3
View File
@@ -23,11 +23,10 @@ This module allows to generate a project/task from sales orders.
],
'assets': {
'web.assets_backend': [
'sale_project/static/src/right_panel/*.js',
'sale_project/static/src/components/project_right_side_panel/**/*',
],
'web.assets_qweb': [
'sale_project/static/src/right_panel/*.xml',
'sale_project/static/src/xml/**/*',
'sale_project/static/src/components/project_right_side_panel/**/*.xml',
],
},
'auto_install': True,
+1 -1
View File
@@ -273,7 +273,7 @@ class Project(models.Model):
def get_action(sol_id):
""" Return the action vals to call it in frontend if the user can access to the SO related """
action, res_id = action_per_sol.get(sol_id, (None, None))
return {'action': action, 'res_id': res_id, 'additional_context': json.dumps({'active_id': sol_id, 'default_project_id': self.id})} if action else {}
return {'action': {'name': action, 'resId': res_id, 'buttonContext': json.dumps({'active_id': sol_id, 'default_project_id': self.id})}} if action else {}
return [{
**sol_read,
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="project.OpenMilestone" t-inherit-mode="extension" owl="1">
<t t-inherit="project.ProjectMilestone" t-inherit-mode="extension" owl="1">
<xpath expr="//t[@t-esc='milestone.name']" position="replace">
<span>
<t t-esc="milestone.name"/>
@@ -9,7 +9,7 @@
</span>
</span>
<span t-if="milestone.allow_billable" t-attf-class="fst-italic {{state.colorClass || 'text-muted'}}">
<t t-if="milestone.sale_line_name" t-esc="milestone.sale_line_name"/>
<t t-if="milestone.sale_line_name" t-esc="milestone.sale_line_name"/>
<span t-if="milestone.quantity_percentage &amp;&amp; milestone.sale_line_name">
(<t t-esc="100 * milestone.quantity_percentage"/>%)
</span>
@@ -1,22 +1,22 @@
/** @odoo-module */
/** @odoo-module */
import { patch } from 'web.utils';
import { patch } from '@web/core/utils/patch';
import { formatFloatTime, formatFloat } from "@web/views/fields/formatters";
import ProjectRightPanel from '@project/js/right_panel/project_right_panel';
import { ProjectRightSidePanel } from '@project/components/project_right_side_panel/project_right_side_panel';
patch(ProjectRightPanel.prototype, '@sale_project/right_panel/project_right_panel', {
patch(ProjectRightSidePanel.prototype, '@sale_project/components/project_right_side_panel/project_right_side_panel', {
async _loadAdditionalSalesOrderItems() {
const offset = this.state.data.sale_items.data.length;
const totalRecords = this.state.data.sale_items.total;
const limit = totalRecords - offset <= 5 ? totalRecords - offset : 5;
const saleOrderItems = await this.rpc({
model: 'project.project',
method: 'get_sale_items_data',
args: [this.project_id, undefined, offset, limit],
kwargs: {
const saleOrderItems = await this.orm.call(
'project.project',
'get_sale_items_data',
[this.projectId, undefined, offset, limit],
{
context: this.context,
}
});
},
);
this.state.data.sale_items.data = [...this.state.data.sale_items.data, ...saleOrderItems];
},
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="sale_project.ProjectRightSidePanel" t-inherit="project.ProjectRightSidePanel" t-inherit-mode="extension" owl="1">
<xpath expr="//ProjectRightSidePanelSection[@name=&quot;'profitability'&quot;]" position="before">
<ProjectRightSidePanelSection
name="'sales'"
show="state.data.sale_items and state.data.sale_items.total &gt; 0"
>
<t t-set-slot="title" owl="1">
Sales
</t>
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Sales Order Items</th>
<th style="width: 20%; text-align: right;">Sold</th>
<th style="width: 20%; text-align: right;">Delivered</th>
<th style="width: 20%; text-align: right;">Invoiced</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.data.sale_items.data" t-as="sale_item" t-key="sale_item.id">
<t t-set="uom_name" t-value="sale_item.product_uom and sale_item.product_uom[1]"/>
<td>
<t t-set="sol_name" t-value="sale_item.display_name"/>
<a t-if="sale_item.action" class="o_rightpanel_button"
t-on-click="() => this.onProjectActionClick(sale_item.action)"
>
<t t-esc="sol_name"/>
</a>
<t t-else="" t-esc="sol_name"/>
</td>
<td align="right"><t t-esc="formatValue(sale_item.product_uom_qty, uom_name)"/> <t t-esc="uom_name"/></td>
<td align="right"><t t-esc="formatValue(sale_item.qty_delivered, uom_name)"/> <t t-esc="uom_name"/></td>
<td align="right"><t t-esc="formatValue(sale_item.qty_invoiced, uom_name)"/> <t t-esc="uom_name"/></td>
</tr>
<tr class="o_rightpanel_nohover" t-if="state.data.sale_items.total &gt; state.data.sale_items.data.length">
<td>
<a class="o_rightpanel_button text-bold" t-on-click="onLoadSalesOrderLinesClick">
Load more
</a>
</td>
</tr>
</tbody>
</table>
</ProjectRightSidePanelSection>
</xpath>
</t>
</templates>
@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="project.ProjectRightPanel" t-inherit-mode="extension" owl="1">
<xpath expr="//t[@t-call='project.StatButtonsSection']" position="after">
<t t-call="sale_project.SalesSection"/>
</xpath>
</t>
<t t-name="sale_project.SalesSection" owl="1">
<t t-set="sale_items" t-value="state.data.sale_items"/>
<t t-if="sale_items and sale_items.total &gt; 0">
<div name="sales" class="o_rightpanel_section">
<div class="o_rightpanel_header">
<div class="o_rightpanel_title">
Sales
</div>
</div>
<div class="o_rightpanel_data">
<table class="w-100 table-striped">
<thead>
<tr>
<th style="width: 40%">Sales Order Items</th>
<th style="width: 20%; text-align: right;">Sold</th>
<th style="width: 20%; text-align: right;">Delivered</th>
<th style="width: 20%; text-align: right;">Invoiced</th>
</tr>
</thead>
<tbody>
<tr t-foreach="sale_items.data" t-as="sale_item" t-key="sale_item.id">
<t t-set="uom_name" t-value="sale_item.product_uom and sale_item.product_uom[1]"/>
<td>
<t t-set="sol_name" t-value="sale_item.display_name"/>
<a t-if="sale_item.action" class="o_rightpanel_button"
t-on-click="onProjectActionClick"
t-att-data-action="sale_item.action"
t-att-data-res_id="sale_item.res_id"
t-att-data-additional_context="sale_item.additional_context"
>
<t t-esc="sol_name"/>
</a>
<t t-else="" t-esc="sol_name"/>
</td>
<td align="right"><t t-esc="formatValue(sale_item.product_uom_qty, uom_name)"/> <t t-esc="uom_name"/></td>
<td align="right"><t t-esc="formatValue(sale_item.qty_delivered, uom_name)"/> <t t-esc="uom_name"/></td>
<td align="right"><t t-esc="formatValue(sale_item.qty_invoiced, uom_name)"/> <t t-esc="uom_name"/></td>
</tr>
<tr class="o_rightpanel_nohover" t-if="sale_items.total &gt; sale_items.data.length">
<td>
<a class="o_rightpanel_button text-bold" t-on-click="onLoadSalesOrderLinesClick">
Load more
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</t>
</t>
</templates>
+1
View File
@@ -43,6 +43,7 @@ have real delivered quantities in sales orders.
'sale_timesheet/static/src/scss/sale_timesheet_portal.scss',
],
'web.assets_backend': [
'sale_timesheet/static/src/components/**/*',
'sale_timesheet/static/src/js/so_line_one2many.js',
],
'web.assets_tests': [
+3 -3
View File
@@ -450,10 +450,10 @@ class Project(models.Model):
action_name = 'action_profitability_items'
def get_timesheets_action(invoice_type, record_ids):
action_params = {'name': action_name, 'type': 'object', 'section': invoice_type, 'domain': json.dumps([('id', 'in', record_ids)])}
args = [invoice_type, [('id', 'in', record_ids)]]
if len(record_ids) == 1:
action_params['res_id'] = record_ids[0]
return action_params
args.append(record_ids[0])
return {'name': action_name, 'type': 'object', 'args': json.dumps(args)}
sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
@@ -0,0 +1,21 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { Many2OneField } from "@web/views/fields/many2one/many2one_field";
export class SoLineField extends Many2OneField {
setup() {
super.setup();
const update = this.update;
this.update = (value, params = {}) => {
update(value, params);
if (value || this.updateOnEmpty) {
this.props.record.update({ is_so_line_edited: true });
}
};
}
}
registry.category("fields").add('so_line_one2many', registry.category('fields').get("one2many")); // TODO: Remove me when the gantt view is converted in OWL
registry.category("fields").add("so_line_field", SoLineField);
@@ -40,7 +40,7 @@ const SoLineMany2one = FieldMany2One.extend({
fieldRegistry.add('so_line_one2many', SoLineOne2Many);
fieldRegistry.add('so_line_many2one', SoLineMany2one);
fieldRegistry.add('so_line_field', SoLineMany2one);
return SoLineOne2Many;
@@ -11,32 +11,24 @@ tour.register('sale_timesheet_tour', {
{
trigger: 'button.o_list_button_add',
content: 'Click on CREATE button to create a quotation with service products.',
run: 'click',
}, {
trigger: 'div[name="partner_id"]',
trigger: 'div[name="partner_id"] input',
content: 'Add the customer for this quotation (e.g. Brandon Freeman)',
run: function (actions) {
actions.text('Brandon Freeman', this.$anchor.find('input'));
},
run: 'text Brandon Freeman',
}, {
trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)',
content: 'Select the first item on the autocomplete dropdown',
run: 'click',
},
{
trigger: 'td.o_field_x2many_list_row_add > a:first-child',
content: 'Click on "Add a product" to add a new product. We will add a service product.',
run: 'click',
}, {
trigger: '.o_field_widget[name="product_id"], .o_field_widget[name="product_template_id"]',
trigger: '.o_field_widget[name="product_id"], .o_field_widget[name="product_template_id"] input',
content: Markup('Select a prepaid service product <i>(e.g. Service Product (Prepaid Hours))</i>'),
run: function (actions) {
actions.text('Service Product (Prepaid Hours)', this.$anchor.find('input'));
},
run: 'text Service Product (Prepaid Hours)',
}, {
trigger: 'ul.ui-menu.ui-widget.ui-autocomplete > li:first-child > a:contains(Service Product (Prepaid Hours))',
content: 'Select the prepaid service product in the autocomplete dropdown',
run: 'click',
}, {
trigger: 'input[name="product_uom_qty"]',
content: "Add 10 hours as ordered quantity for this product.",
@@ -44,88 +36,64 @@ tour.register('sale_timesheet_tour', {
}, {
trigger: 'button[name="action_confirm"]',
content: 'Click on Confirm button to create a sale order with this quotation.',
run: 'click',
}, {
trigger: 'button.o_form_button_save',
extra_trigger: '.o_field_widget[name=state] button[data-value=sale].btn-primary',
content: 'Click on Save button to save the Sales Order.',
run: 'click',
}, {
trigger: '.o_form_readonly',
content: 'Save is done and form is reloaded.',
run: 'click',
}, tour.stepUtils.toggleHomeMenu(),
...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project app.'),
{
trigger: 'button.o-kanban-button-new',
content: 'Add a new project.',
run: 'click',
}, {
trigger: '.o_field_widget.o_project_name input',
content: 'Select your project name (e.g. Project for Freeman)',
run: function (actions) {
actions.text('Project for Freeman', this.$anchor);
},
run: 'text Project for Freeman',
}, {
trigger: 'button[name="action_view_tasks"]',
content: 'Click on Create button to create and enter to this newest project.',
run: 'click',
}, {
trigger: 'div.o_kanban_header > div:first-child',
trigger: 'div.o_kanban_header > div:first-child input',
content: 'Select a name of your kanban column (e.g. To Do)',
run: function (actions) {
actions.text('To Do', this.$anchor.find('input'));
},
run: 'text To Do',
}, {
trigger: 'button.o_kanban_add',
content: 'Click on Add button to create the column.',
run: 'click',
}, {
trigger: 'button.o-kanban-button-new',
content: 'Click on Create button to create a task into your project.',
run: 'click',
}, {
trigger: 'input[name="name"]',
trigger: 'div[name="name"] > input',
content: 'Select the name of the task (e.g. Onboarding)',
run: function (actions) {
actions.text('Onboarding', this.$anchor);
}
run: 'text Onboarding',
}, {
trigger: 'button.o_kanban_edit',
content: 'Click on Edit button to enter to the form view of the task.',
position: 'bottom',
run: 'click',
}, {
trigger: 'div[name="partner_id"]',
trigger: 'div[name="partner_id"] input',
content: Markup('Select the customer of your Sales Order <i>(e.g. Brandon Freeman)</i>. Since we have a Sales Order for this customer with a prepaid service product which the remaining hours to deliver is greater than 0, the Sales Order Item in the task should be contain the Sales Order Item containing this prepaid service product.'),
run: function (actions) {
actions.text('Brandon Freeman', this.$anchor.find('input'));
},
run: 'text Brandon Freeman',
}, {
trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)',
content: 'Select the customer in the autocomplete dropdown.',
run: 'click',
}, {
trigger: 'div.o_notebook_headers',
trigger: 'a.nav-link:contains(Timesheets)',
extra_trigger: 'div.o_notebook_headers',
content: 'Click on Timesheets page to log a timesheet',
run: function (actions) {
const notebookId = $('div[name="timesheet_ids"]').closest("div.tab-pane").attr('id');
actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`));
},
}, {
trigger: 'div[name="timesheet_ids"] td.o_field_x2many_list_row_add a[role="button"]',
content: 'Click on Add a line to create a new timesheet into the task.',
run: 'click',
}, {
trigger: 'input[name="unit_amount"]',
trigger: 'div[name="unit_amount"] input',
content: 'Enter one hour for this timesheet',
run: function (actions) {
actions.text('1', this.$anchor);
},
run: 'text 1',
}, {
trigger: 'i.o_optional_columns_dropdown_toggle',
content: 'The so_line field should be hidden by default. We check if it is the case by adding this field in the timesheet list view',
run: 'click',
}, {
trigger: 'input[name="so_line"]',
content: 'Check the so_line field to display the column on the list view.',
@@ -137,7 +105,6 @@ tour.register('sale_timesheet_tour', {
}, {
trigger: 'button[name="action_view_so"]',
content: 'Click on this stat button to see the SO linked to the SOL of the task.',
run: 'click',
}, {
trigger: 'div[name="order_line"]',
content: 'Check if the quantity delivered is equal to 1 hour.',
@@ -159,24 +126,18 @@ tour.register('sale_timesheet_tour', {
}, {
trigger: 'button[data-menu-xmlid="project.menu_project_config"]',
content: 'Click on the Configuration menu.',
run: 'click',
}, {
trigger: '.dropdown-item[data-menu-xmlid="project.menu_projects_config"]',
content: 'Select Configuration > Projects.',
run: 'click',
}, {
trigger: 'button.o_list_button_add',
content: 'Click on Create button to create a new project and see the different configuration available for the project.',
run: 'click',
}, {
trigger: 'div.o_notebook_headers',
trigger: 'a.nav-link[name="settings"]',
extra_trigger: 'div.o_notebook_headers',
content: 'Click on Settings page to check the allow_billable checkbox',
run: function (actions) {
const notebookId = $('div[name="allow_billable"]').closest("div.tab-pane").attr('id');
actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`));
},
}, {
trigger: 'div[name="allow_billable"] > input',
trigger: 'div[name="allow_billable"] input',
content: 'Check the allow_billable',
run: function (actions) {
if (!this.$anchor.prop('checked')) {
@@ -184,93 +145,72 @@ tour.register('sale_timesheet_tour', {
}
}
}, {
trigger: 'div[name="partner_id"]',
trigger: 'div[name="partner_id"] input',
content: Markup('Add the customer for this project to select an SO and SOL for this customer <i>(e.g. Brandon Freeman)</i>.'),
run: function (actions) {
actions.text('Azure Interior, Brandon Freeman', this.$anchor.find('input'));
},
run: 'text Brandon Freeman',
}, {
trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)',
content: 'Select the customer in the autocomplete dropdown',
run: 'click',
}, {
trigger: 'div.o_notebook_headers',
trigger: 'a.nav-link[name="billing_employee_rate"]',
extra_trigger: 'div.o_notebook_headers',
content: 'Click on Invoicing tab to configure the invoicing of this project.',
run: function (actions) {
const notebookId = $('div[name="sale_line_id"]').closest("div.tab-pane").attr('id');
actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`));
},
}, {
trigger: 'div[name="sale_line_id"]',
trigger: 'div[name="sale_line_id"] input',
content: 'Select a Sales Order Item as Default Sales Order Item for each task in this project.',
run: function (actions) {
actions.text('S', this.$anchor.find('input'));
},
run: 'text S',
}, {
trigger: 'ul.ui-autocomplete > li:first-child > a',
content: 'Select the Sales Order Item in the autocomplete dropdown.',
run: 'click',
}, {
trigger: 'div[name="sale_line_employee_ids"] td.o_field_x2many_list_row_add > a[role="button"]',
content: 'Click on Add a line on the mapping list view.',
run: 'click',
}, {
trigger: 'div[name="sale_line_employee_ids"] div[name="employee_id"] input',
content: 'Select an employee to link a Sales Order Item on his timesheets into this project.',
run: 'click',
}, {
trigger: 'ul.ui-autocomplete > li:first-child > a',
content: 'Select the first employee in the autocomplete dropdown',
run: 'click',
}, {
trigger: 'div[name="sale_line_employee_ids"] div[name="sale_line_id"]',
trigger: 'div[name="sale_line_employee_ids"] div[name="sale_line_id"] input',
content: 'Select the Sales Order Item to link to the timesheets of this employee.',
position: 'bottom',
run: function (actions) {
actions.text('S', this.$anchor.find('input'));
},
run: 'text S',
}, {
trigger: 'ul.ui-autocomplete > li:first-child > a',
content: 'Select the first Sales Order Item in the autocomplete dropdown.',
run: 'click',
}, {
trigger: 'input[name="name"]',
trigger: 'h1 > div[name="name"] > input',
content: 'Set Project name',
run: function (actions) {
actions.text('Project with employee mapping', this.$anchor);
},
run: 'text Project with employee mapping',
}, {
trigger: '.o_form_button_save',
content: 'Save Project',
}, {
trigger: '.dropdown-item[data-menu-xmlid="project.menu_projects"]',
content: 'Select Projects',
trigger: '.dropdown-item[data-menu-xmlid="project.menu_main_pm"]',
content: 'Select Project main menu',
}, {
// an invisible element cannot be used as a trigger so this small hack is mandatory for the next step
trigger: 'div.o_kanban_primary_left :contains("Project for Freeman")',
content: 'Open the project dropdown',
run: function () {
$('.o_kanban_record:contains("Project for Freeman") .o_dropdown_kanban').css('visibility', 'visible');
},
}, {
trigger: '.oe_kanban_global_click :contains("Project for Freeman") .o_dropdown_kanban',
trigger: '.oe_kanban_global_click :contains("Project for Freeman") button.o_dropdown_kanban',
content: 'Open the project dropdown',
}, {
trigger: '.o_kanban_record:contains("Project for Freeman") .dropdown-menu a:contains("Edit")',
content: 'Start editing the project',
// timer: 300,
}, {
trigger: 'div.o_notebook_headers',
trigger: 'div[name="partner_id"] input',
content: Markup('Add the customer for this project to select an SO and SOL for this customer <i>(e.g. Brandon Freeman)</i>.'),
run: 'text Brandon Freeman',
}, {
trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)',
content: 'Select the customer in the autocomplete dropdown',
}, {
trigger: 'a.nav-link[name="billing_employee_rate"]',
extra_trigger: 'div.o_notebook_headers',
content: 'Click on Invoicing tab to configure the invoicing of this project.',
run: function (actions) {
const notebookId = $('div[name="sale_line_id"]').closest("div.tab-pane").attr('id');
actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`));
},
}, {
trigger: 'div[name="sale_line_id"]',
trigger: 'div[name="sale_line_id"] input',
content: 'Select the first sale order of the list',
run: function (actions) {
actions.text('Prepaid', this.$anchor.find('input'));
},
run: 'text Prepaid',
}, {
trigger: 'ul.ui-autocomplete > li:first-child > a',
content: 'Select the first item on the autocomplete dropdown',
@@ -287,45 +227,46 @@ tour.register('sale_timesheet_tour', {
trigger: ".o_project_updates_breadcrumb",
content: 'Open Updates',
}, {
trigger: ".o_rightpanel_title:eq(0):contains('Sales')",
trigger: ".o_rightpanel_section[name='sales'] .o_rightpanel_title:contains('Sales')",
content: 'Check the user sees Sales section',
run: function () {},
}, {
trigger: ".o_rightpanel_data:eq(0):contains('Prepaid Hours')",
trigger: ".o_rightpanel_section[name='sales'] .o_rightpanel_data:contains('Prepaid Hours')",
content: 'Check the user sees a line in the Sales section',
// timer: 300,
run: function () {},
}, {
trigger: ".oe_button_box .o_stat_text:contains('Sales Orders')",
trigger: ".o_rightpanel_section .oe_button_box .o_stat_text:contains('Sales Orders')",
content: 'Check the user sees Sales Orders Stat Button',
run: function () {},
}, {
trigger: ".o_rightpanel_title:eq(1):contains('Profitability')",
trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_title:contains('Profitability')",
content: 'Check the user sees Profitability section',
run: function () {},
}, {
trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data > .o_rightpanel_subsection:eq(0) > table > thead > tr > th:eq(0):contains('Revenues')",
trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_data > .o_rightpanel_subsection:eq(0) > table > thead > tr > th:eq(0):contains('Revenues')",
content: 'Check the user sees Profitability subsection row',
run: function () {},
}, {
trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data > .o_rightpanel_subsection:eq(1) > table > thead > tr > th:eq(0):contains('Costs')",
trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_data > .o_rightpanel_subsection:eq(1) > table > thead > tr > th:eq(0):contains('Costs')",
content: 'Check the user sees Profitability subsection row',
run: function () {},
}, {
trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data > .o_rightpanel_subsection:eq(2) > table > thead > tr > th:eq(0):contains('Margin')",
trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_data > .o_rightpanel_subsection:eq(2) > table > thead > tr > th:eq(0):contains('Margin')",
content: 'Check the user sees Profitability subsection row',
run: function () {},
}, {
trigger: ".o_rightpanel_section .o_rightpanel_title:contains('Milestones')",
trigger: ".o_rightpanel_section[name='milestones'] .o_rightpanel_title:contains('Milestones')",
content: 'Check the user sees Milestones section',
}, {
trigger: ".o_add_milestone a",
content: "Add a first milestone",
}, {
trigger: "input.o_field_widget[name=name]",
trigger: "div.o_field_widget[name=name] input",
content: "Edit new Milestone",
run: 'text New milestone',
}, {
trigger: "input.datetimepicker-input[name=deadline]",
trigger: "div[name=deadline] .datetimepicker-input",
content: "Edit new Milestone",
run: 'text 12/12/2099',
}, {
@@ -335,7 +276,7 @@ tour.register('sale_timesheet_tour', {
trigger: ".o-kanban-button-new",
content: "Create new Project Update",
}, {
trigger: "input.o_field_widget[name=name]",
trigger: "div.o_field_widget[name=name] input",
content: "Give a name to Project Update",
run: 'text New update',
}, {
@@ -44,7 +44,7 @@
<xpath expr="//tree/field[@name='name']" position="after">
<field name="commercial_partner_id" invisible="1" groups="sales_team.group_sale_salesman"/>
<field name="is_so_line_edited" invisible="1" groups="sales_team.group_sale_salesman"/>
<field name="so_line" widget="so_line_many2one" optional="hide" options="{'no_create': True}" context="{'create': False, 'edit': False, 'delete': False}" groups="sales_team.group_sale_salesman"/>
<field name="so_line" widget="so_line_field" optional="hide" options="{'no_create': True}" context="{'create': False, 'edit': False, 'delete': False}" groups="sales_team.group_sale_salesman"/>
</xpath>
</field>
</record>
@@ -57,7 +57,7 @@
<xpath expr="//field[@name='task_id']" position="after">
<field name="commercial_partner_id" invisible="1" groups="sales_team.group_sale_salesman"/>
<field name="is_so_line_edited" invisible="1" groups="sales_team.group_sale_salesman"/>
<field name="so_line" widget="so_line_many2one" options='{"no_create": True}' context="{'create': False, 'edit': False, 'delete': False}" groups="sales_team.group_sale_salesman"/>
<field name="so_line" widget="so_line_field" options='{"no_create": True}' context="{'create': False, 'edit': False, 'delete': False}" groups="sales_team.group_sale_salesman"/>
</xpath>
</field>
</record>
@@ -406,7 +406,7 @@ tour.stepUtils.autoExpandMoreButtons('.o_form_readonly'),
position: "left",
}, {
mobile: true,
trigger: '.o_field_widget[name=name]input',
trigger: '.o_field_widget[name=name] input',
extra_trigger: ".modal:not(.o_inactive_modal) .modal-title:contains('Project')",
content: _t('Let\'s enter the name.'),
position: 'left',
@@ -847,14 +847,14 @@ tour.stepUtils.mobileModifier(tour.stepUtils.autoExpandMoreButtons('.o_control_p
content: _t('See Tasks'),
position: 'bottom',
}, {
trigger: '.o_field_widget[name=project_id]',
trigger: '.o_field_widget[name=project_id] a',
content: _t('See Project'),
position: 'right',
},
tour.stepUtils.autoExpandMoreButtons('.o_control_panel .breadcrumb:contains("the_flow.project")'),
{
trigger: '.oe_button_box .oe_stat_button:has(span:contains("Recorded"))',
extra_trigger: '.o_form_readonly',
trigger: '.oe_button_box button.oe_stat_button:has(span:contains("Recorded"))',
extra_trigger: '.o_form_readonly .oe_stat_button.dropdown > .dropdown-menu',
content: _t('See Timesheets'),
position: 'bottom',
}, {
@@ -923,6 +923,13 @@ tour.stepUtils.autoExpandMoreButtons('.o_control_panel .breadcrumb:contains("the
content: _t("Select the the_flow.vendor"),
position: "bottom",
}, {
edition: 'community',
trigger: '.o_selected_row .o_field_widget[name=unit_amount] input',
content: _t('Set time'),
position: 'bottom',
run: 'text 10',
}, {
edition: 'enterprise',
trigger: '.o_selected_row .o_field_widget[name=unit_amount]input',
content: _t('Set time'),
position: 'bottom',