19.0 vanilla
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,24 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
|
||||
<defs>
|
||||
<path id="icon-a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/>
|
||||
<linearGradient id="icon-c" x1="98.162%" x2="0%" y1="1.838%" y2="100%">
|
||||
<stop offset="0%" stop-color="#797DA5"/>
|
||||
<stop offset="50.799%" stop-color="#6D7194"/>
|
||||
<stop offset="100%" stop-color="#626584"/>
|
||||
</linearGradient>
|
||||
<path id="icon-d" d="M56.583 23.333h-3.5c-.518 0-.983.226-1.304.584h-1.715l-2.27-2.647-.012-.013a7.581 7.581 0 0 0-5.707-2.59h-3.394c-1.294 0-2.545.36-3.623 1.021a8.19 8.19 0 0 0-3.95-1.021h-2.342c-2.107 0-4.2.818-5.775 2.391l-2.857 2.859H18.22a1.745 1.745 0 0 0-1.304-.584h-3.5a1.75 1.75 0 0 0-1.75 1.75v17.5c0 .967.783 1.75 1.75 1.75h3.5a1.75 1.75 0 0 0 1.65-1.166h1.37l5.495 4.927c1.862 1.928 4.37 3.24 7.042 3.24 1.195 0 2.354-.281 3.362-.798 1.818.037 3.726-.756 5.036-2.29a7.109 7.109 0 0 0 3.698-2.523c1.53-.32 2.97-1.202 3.896-2.556h2.967a1.75 1.75 0 0 0 1.65 1.166h3.5a1.75 1.75 0 0 0 1.75-1.75v-17.5a1.75 1.75 0 0 0-1.75-1.75zM15.167 42a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333zm30.08-.42c-1.12 1.042-2.69.826-2.914.583.103.976-1.331 2.993-3.579 2.835-.404 1.351-2.057 2.467-3.754 1.878-.648.648-1.638.957-2.526.957-1.82 0-3.483-1.06-4.604-2.254l-5.928-5.316a2.332 2.332 0 0 0-1.557-.596h-1.718v-12.25h1.95c.619 0 1.212-.246 1.65-.684l3.2-3.2a4.667 4.667 0 0 1 3.3-1.366h2.34c.424 0 .84.057 1.24.167l-3.155 3.682a5.376 5.376 0 0 0-.048 6.96c2.362 2.833 6.663 2.86 9.077.143l1.894-2.193 5.282 6.99c.98 1.065.799 2.781-.15 3.664zm6.086-1.913H49.55a5.985 5.985 0 0 0-1.441-3.967l-5.693-7.534a1.752 1.752 0 0 0-2.907-1.894l-3.911 4.53a2.49 2.49 0 0 1-3.765-.068 1.885 1.885 0 0 1 .016-2.44l4.224-4.928a3.434 3.434 0 0 1 2.608-1.2h3.394c1.175 0 2.293.507 3.068 1.389l3.312 3.862h2.878v12.25zm3.5 2.333a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333z"/>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<mask id="icon-b" fill="#fff">
|
||||
<use xlink:href="#icon-a"/>
|
||||
</mask>
|
||||
<g mask="url(#icon-b)">
|
||||
<path fill="url(#icon-c)" d="M0 0H70V70H0z"/>
|
||||
<path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/>
|
||||
<path fill="#000" d="M33.423 69H4.006C2.003 69 0 68.854 0 64.911V38.065l12.348-13.998 8.686.293L29.375 21h14.023l6.01 4.089L58 24.36v19.558L33.423 69z" opacity=".165"/>
|
||||
<path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/>
|
||||
<path fill="#000" fill-rule="nonzero" d="M56.583 25.333h-3.5c-.518 0-.983.226-1.304.584h-1.715l-2.27-2.647-.012-.013a7.581 7.581 0 0 0-5.707-2.59h-3.394c-1.294 0-2.545.36-3.623 1.021a8.19 8.19 0 0 0-3.95-1.021h-2.342c-2.107 0-4.2.818-5.775 2.391l-2.857 2.859H18.22a1.745 1.745 0 0 0-1.304-.584h-3.5a1.75 1.75 0 0 0-1.75 1.75v17.5c0 .967.783 1.75 1.75 1.75h3.5a1.75 1.75 0 0 0 1.65-1.166h1.37l5.495 4.927c1.862 1.928 4.37 3.24 7.042 3.24 1.195 0 2.354-.281 3.362-.798 1.818.037 3.726-.756 5.036-2.29a7.109 7.109 0 0 0 3.698-2.523c1.53-.32 2.97-1.202 3.896-2.556h2.967a1.75 1.75 0 0 0 1.65 1.166h3.5a1.75 1.75 0 0 0 1.75-1.75v-17.5a1.75 1.75 0 0 0-1.75-1.75zM15.167 44a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333zm30.08-.42c-1.12 1.042-2.69.826-2.914.583.103.976-1.331 2.993-3.579 2.835-.404 1.351-2.057 2.467-3.754 1.878-.648.648-1.638.957-2.526.957-1.82 0-3.483-1.06-4.604-2.254l-5.928-5.316a2.332 2.332 0 0 0-1.557-.596h-1.718v-12.25h1.95c.619 0 1.212-.246 1.65-.684l3.2-3.2a4.667 4.667 0 0 1 3.3-1.366h2.34c.424 0 .84.057 1.24.167l-3.155 3.682a5.376 5.376 0 0 0-.048 6.96c2.362 2.833 6.663 2.86 9.077.143l1.894-2.193 5.282 6.99c.98 1.065.799 2.781-.15 3.664zm6.086-1.913H49.55a5.985 5.985 0 0 0-1.441-3.967l-5.693-7.534a1.752 1.752 0 0 0-2.907-1.894l-3.911 4.53a2.49 2.49 0 0 1-3.765-.068 1.885 1.885 0 0 1 .016-2.44l4.224-4.928a3.434 3.434 0 0 1 2.608-1.2h3.394c1.175 0 2.293.507 3.068 1.389l3.312 3.862h2.878v12.25zm3.5 2.333a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333z" opacity=".372"/>
|
||||
<use fill="#FFF" fill-rule="nonzero" xlink:href="#icon-d"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M45.873 9c3.52.041 5.504 4.474 3.013 6.964l-8.424 8.419L36.5 27 25 9h20.873ZM10.946 25.79a3.972 3.972 0 0 0 0 5.618l8.433 8.43a3.977 3.977 0 0 0 5.623 0L23.5 36 15 27l-4.055-1.212Z" fill="#985184"/><path d="M1.114 15.964C-1.377 13.474.608 9.041 4.128 9H25l15.461 15.383a3.972 3.972 0 0 1 0 5.62l-9.84 9.833a3.977 3.977 0 0 1-5.621 0L1.114 15.964Z" fill="#1AD3BB"/><path d="M25 39.837a3.972 3.972 0 0 0 0-5.62l-8.434-8.428a3.977 3.977 0 0 0-5.623 0L25 39.837Zm-7.38-23.531L25 9l9.136 9.062a5.966 5.966 0 0 0-8.433 0l-3.163 3.16a3.48 3.48 0 0 1-4.92 0 3.475 3.475 0 0 1 0-4.916Z" fill="#005E7A"/></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 693 B |
BIN
odoo-bringout-oca-ocb-crm/crm/static/description/icon_hi.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
|
|
@ -0,0 +1,55 @@
|
|||
import { Domain } from "@web/core/domain";
|
||||
import { ActivityMenu } from "@mail/core/web/activity_menu";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ActivityMenu.prototype, {
|
||||
availableViews(group) {
|
||||
if (group.model === "crm.lead") {
|
||||
return [
|
||||
[false, "list"],
|
||||
[false, "kanban"],
|
||||
[false, "form"],
|
||||
[false, "calendar"],
|
||||
[false, "pivot"],
|
||||
[false, "graph"],
|
||||
[false, "activity"],
|
||||
];
|
||||
}
|
||||
return super.availableViews(...arguments);
|
||||
},
|
||||
|
||||
openActivityGroup(group, filter = "all", newWindow) {
|
||||
// fetch the data from the button otherwise fetch the ones from the parent (.o_ActivityMenuView_activityGroup).
|
||||
const context = {};
|
||||
if (group.model === "crm.lead") {
|
||||
this.dropdown.close();
|
||||
if (filter === "my" || filter === "all") {
|
||||
context["search_default_activities_overdue"] = 1;
|
||||
context["search_default_activities_today"] = 1;
|
||||
} else if (filter === "overdue") {
|
||||
context["search_default_activities_overdue"] = 1;
|
||||
} else if (filter === "today") {
|
||||
context["search_default_activities_today"] = 1;
|
||||
} else {
|
||||
context["search_default_activities_upcoming_all"] = 1;
|
||||
}
|
||||
// Necessary because activity_ids of mail.activity.mixin has auto_join
|
||||
// So, duplicates are faking the count and "Load more" doesn't show up
|
||||
context["force_search_count"] = 1;
|
||||
this.action.loadAction("crm.crm_lead_action_my_activities").then((action) => {
|
||||
// to show lost leads in the activity
|
||||
action.domain = Domain.and([
|
||||
action.domain || [],
|
||||
[["active", "in", [true, false]]],
|
||||
]).toList();
|
||||
this.action.doAction(action, {
|
||||
newWindow,
|
||||
additionalContext: context,
|
||||
clearBreadcrumbs: true,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return super.openActivityGroup(...arguments);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { fields, Record } from "@mail/core/common/record";
|
||||
import { router } from "@web/core/browser/router";
|
||||
|
||||
export class CrmLead extends Record {
|
||||
static id = "id";
|
||||
static _name = "crm.lead";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
name;
|
||||
href = fields.Attr("", {
|
||||
compute() {
|
||||
return router.stateToUrl({ model: 'crm.lead', resId: this.id });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
CrmLead.register();
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { ResPartner } from "@mail/core/common/res_partner_model";
|
||||
import { fields } from "@mail/core/common/record";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ResPartner.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.opportunity_ids = fields.Many("crm.lead");
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB |
BIN
odoo-bringout-oca-ocb-crm/crm/static/src/img/milk-autofill.gif
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
many2OneAvatarUserField,
|
||||
Many2OneAvatarUserField,
|
||||
} from "@mail/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class Many2OneAvatarLeaderUserField extends Many2OneAvatarUserField {
|
||||
static props = {
|
||||
...Many2OneAvatarUserField.props,
|
||||
teamField: String,
|
||||
};
|
||||
|
||||
get m2oProps() {
|
||||
return {
|
||||
...super.m2oProps,
|
||||
context: {
|
||||
...super.m2oProps.context,
|
||||
crm_formatted_display_name_team: Number(this.props.record.data[this.props.teamField].id),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("many2one_avatar_leader_user", {
|
||||
...many2OneAvatarUserField,
|
||||
component: Many2OneAvatarLeaderUserField,
|
||||
extractProps: (fieldInfo, dynamicInfo) => ({
|
||||
...many2OneAvatarUserField.extractProps(fieldInfo, dynamicInfo),
|
||||
teamField: fieldInfo.attrs.teamField,
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,90 +1,101 @@
|
|||
/** @odoo-module **/
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
import { _t } from 'web.core';
|
||||
import { Markup } from 'web.utils';
|
||||
import tour from 'web_tour.tour';
|
||||
import { markup } from "@odoo/owl";
|
||||
|
||||
tour.register('crm_tour', {
|
||||
url: "/web",
|
||||
rainbowManMessage: _t("Congrats, best of luck catching such big fish! :)"),
|
||||
sequence: 10,
|
||||
}, [tour.stepUtils.showAppsMenuItem(), {
|
||||
registry.category("web_tour.tours").add('crm_tour', {
|
||||
url: "/odoo",
|
||||
steps: () => [stepUtils.showAppsMenuItem(), {
|
||||
isActive: ["community"],
|
||||
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
|
||||
content: Markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
|
||||
position: 'bottom',
|
||||
edition: 'community',
|
||||
content: markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
isActive: ["enterprise"],
|
||||
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
|
||||
content: Markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
|
||||
position: 'bottom',
|
||||
edition: 'enterprise',
|
||||
content: markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_opportunity_kanban .o_kanban_renderer",
|
||||
},
|
||||
{
|
||||
trigger: '.o_opportunity_kanban .o-kanban-button-new',
|
||||
content: markup(_t("<b>Create your first opportunity.</b>")),
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '.o-kanban-button-new',
|
||||
extra_trigger: '.o_opportunity_kanban',
|
||||
content: Markup(_t("<b>Create your first opportunity.</b>")),
|
||||
position: 'bottom',
|
||||
trigger: ".o_kanban_quick_create .o_field_widget[name='commercial_partner_id'] input",
|
||||
content: markup(_t('<b>Write a few letters</b> to look for a company, or create a new one.')),
|
||||
tooltipPosition: "top",
|
||||
run: "edit Brandon Freeman",
|
||||
}, {
|
||||
trigger: ".o_kanban_quick_create .o_field_widget[name='partner_id']",
|
||||
content: Markup(_t('<b>Write a few letters</b> to look for a company, or create a new one.')),
|
||||
position: "top",
|
||||
run: function (actions) {
|
||||
actions.text("Brandon Freeman", this.$anchor.find("input"));
|
||||
},
|
||||
isActive: ["auto"],
|
||||
trigger: ".ui-menu-item > a:contains('Brandon Freeman')",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".ui-menu-item > a",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
trigger: ".o_kanban_quick_create .o_field_widget[name='name'] input:value('Brandon Freeman')",
|
||||
}, {
|
||||
trigger: ".o_kanban_quick_create .o_kanban_add",
|
||||
content: Markup(_t("Now, <b>add your Opportunity</b> to your Pipeline.")),
|
||||
position: "bottom",
|
||||
content: markup(_t("Now, <b>add your Opportunity</b> to your Pipeline.")),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_opportunity_kanban .o_kanban_renderer",
|
||||
},
|
||||
{
|
||||
trigger: ".o_opportunity_kanban:not(:has(.o_view_sample_data)) .o_kanban_group .o_kanban_record:last-of-type",
|
||||
content: markup(_t("<b>Drag & drop opportunities</b> between columns as you progress in your sales cycle.")),
|
||||
tooltipPosition: "right",
|
||||
run: "drag_and_drop(.o_opportunity_kanban .o_kanban_group:eq(2))",
|
||||
},
|
||||
{
|
||||
trigger: ".o_opportunity_kanban .o_kanban_renderer",
|
||||
},
|
||||
{
|
||||
// Choose the element that is not going to be moved by the previous step.
|
||||
trigger: ".o_opportunity_kanban .o_kanban_group .o_kanban_record .o-mail-ActivityButton",
|
||||
content: markup(_t("Looks like nothing is planned. :(<br><br><i>Tip: Schedule activities to keep track of everything you have to do!</i>")),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_opportunity_kanban .o_kanban_renderer",
|
||||
},
|
||||
{
|
||||
trigger: ".o-mail-ActivityListPopover button:contains(Schedule an activity)",
|
||||
content: markup(_t("Let's <b>Schedule an Activity.</b>")),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".o_opportunity_kanban .o_kanban_group:first-child .o_kanban_record:last-of-type .oe_kanban_content",
|
||||
extra_trigger: ".o_opportunity_kanban",
|
||||
content: Markup(_t("<b>Drag & drop opportunities</b> between columns as you progress in your sales cycle.")),
|
||||
position: "right",
|
||||
run: "drag_and_drop_native .o_opportunity_kanban .o_kanban_group:eq(2) ",
|
||||
}, {
|
||||
trigger: ".o_kanban_record:not(.o_updating) .o_ActivityButtonView",
|
||||
extra_trigger: ".o_opportunity_kanban",
|
||||
content: Markup(_t("Looks like nothing is planned. :(<br><br><i>Tip : Schedule activities to keep track of everything you have to do!</i>")),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_ActivityListView_addActivityButton",
|
||||
extra_trigger: ".o_opportunity_kanban",
|
||||
content: Markup(_t("Let's <b>Schedule an Activity.</b>")),
|
||||
position: "bottom",
|
||||
width: 200,
|
||||
}, {
|
||||
trigger: '.modal-footer button[name="action_close_dialog"]',
|
||||
content: Markup(_t("All set. Let’s <b>Schedule</b> it.")),
|
||||
position: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
|
||||
run: function (actions) {
|
||||
actions.auto('.modal-footer button[special=cancel]');
|
||||
},
|
||||
trigger: '.modal-footer button[name="action_schedule_activities"]',
|
||||
content: markup(_t("All set. Let’s <b>Schedule</b> it.")),
|
||||
tooltipPosition: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
|
||||
run: "click",
|
||||
}, {
|
||||
id: "drag_opportunity_to_won_step",
|
||||
trigger: ".o_opportunity_kanban .o_kanban_record:last-of-type",
|
||||
content: Markup(_t("Drag your opportunity to <b>Won</b> when you get the deal. Congrats !")),
|
||||
position: "bottom",
|
||||
run: "drag_and_drop_native .o_opportunity_kanban .o_kanban_group:eq(3) ",
|
||||
}, {
|
||||
content: markup(_t("Drag your opportunity to <b>Won</b> when you get the deal. Congrats!")),
|
||||
tooltipPosition: "right",
|
||||
run: "drag_and_drop(.o_opportunity_kanban .o_kanban_group:eq(3))",
|
||||
},
|
||||
{
|
||||
trigger: ".o_kanban_record",
|
||||
extra_trigger: ".o_opportunity_kanban",
|
||||
content: _t("Let’s have a look at an Opportunity."),
|
||||
position: "right",
|
||||
run: function (actions) {
|
||||
actions.auto(".o_kanban_record");
|
||||
},
|
||||
tooltipPosition: "right",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".o_lead_opportunity_form .o_statusbar_status",
|
||||
content: _t("You can make your opportunity advance through your pipeline from here."),
|
||||
position: "bottom"
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".breadcrumb-item:not(.active):first",
|
||||
content: _t("Click on the breadcrumb to go back to your Pipeline. Odoo will save all modifications as you navigate."),
|
||||
position: "bottom",
|
||||
run: function (actions) {
|
||||
actions.auto(".breadcrumb-item:not(.active):last");
|
||||
}
|
||||
}]);
|
||||
tooltipPosition: "bottom",
|
||||
run: "click .breadcrumb-item:not(.active):last",
|
||||
}]});
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
registerPatch({
|
||||
name: 'ActivityGroupView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onClickFilterButton(ev) {
|
||||
// fetch the data from the button otherwise fetch the ones from the parent (.o_ActivityMenuView_activityGroup).
|
||||
var data = _.extend({}, $(ev.currentTarget).data(), $(ev.target).data());
|
||||
var context = {};
|
||||
if (data.res_model === "crm.lead") {
|
||||
this.activityMenuViewOwner.update({ isOpen: false });
|
||||
if (data.filter === 'my') {
|
||||
context['search_default_activities_overdue'] = 1;
|
||||
context['search_default_activities_today'] = 1;
|
||||
} else {
|
||||
context['search_default_activities_' + data.filter] = 1;
|
||||
}
|
||||
// Necessary because activity_ids of mail.activity.mixin has auto_join
|
||||
// So, duplicates are faking the count and "Load more" doesn't show up
|
||||
context['force_search_count'] = 1;
|
||||
this.env.services['action'].doAction('crm.crm_lead_action_my_activities', {
|
||||
additionalContext: context,
|
||||
clearBreadcrumbs: true,
|
||||
});
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from "@mail/model/model_core";
|
||||
|
||||
registerPatch({
|
||||
name: "ir.model",
|
||||
fields: {
|
||||
availableWebViews: {
|
||||
compute() {
|
||||
if (this.model === "crm.lead") {
|
||||
return [
|
||||
'list',
|
||||
'kanban',
|
||||
'form',
|
||||
'calendar',
|
||||
'pivot',
|
||||
'graph',
|
||||
'activity',
|
||||
];
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,76 +1,16 @@
|
|||
.o_lead_opportunity_form {
|
||||
// Used to add spacing between fields when placed inline without having
|
||||
// empty fields take extra space.
|
||||
div.o_lead_opportunity_form_inline_fields > :not(.o_field_empty) {
|
||||
margin-right: 1.5em;
|
||||
}
|
||||
.crm_lead_merge_summary blockquote {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.o_opportunity_kanban {
|
||||
.ribbon {
|
||||
&::before, &::after {
|
||||
display: none;
|
||||
}
|
||||
span {
|
||||
padding: 5px;
|
||||
font-size: small;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
.crm_quick_create_opportunity_form_group {
|
||||
|
||||
.ribbon-top-right {
|
||||
margin-top: -9px;
|
||||
span {
|
||||
left: 12px;
|
||||
right: 30px;
|
||||
height: 25px;
|
||||
top: 18px;
|
||||
}
|
||||
}
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
.oe_kanban_card_ribbon {
|
||||
min-height: 105px;
|
||||
.o_kanban_record_title {
|
||||
max-width: calc(100% - 65px);
|
||||
}
|
||||
.o_kanban_record_subtitle {
|
||||
max-width: calc(100% - 35px);
|
||||
}
|
||||
i.fa {
|
||||
text-align: center;
|
||||
min-width: 2rem;
|
||||
}
|
||||
.crm_revenues .o_row {
|
||||
margin-left: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.crm_lead_merge_summary {
|
||||
blockquote {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
// Tag colors
|
||||
@for $size from 1 through length($o-colors) {
|
||||
.o_tag_color_#{$size - 1} {
|
||||
border: transparent;
|
||||
line-height: normal;
|
||||
$background-color: white;
|
||||
// no color selected
|
||||
@if $size == 1 {
|
||||
& {
|
||||
color: black;
|
||||
background-color: $background-color;
|
||||
box-shadow: inset 0 0 0 1px nth($o-colors, $size);
|
||||
}
|
||||
} @else {
|
||||
$background-color: nth($o-colors, $size);
|
||||
& {
|
||||
color: white;
|
||||
background-color: $background-color;
|
||||
}
|
||||
}
|
||||
@at-root a#{&} {
|
||||
&:hover {
|
||||
color: color-contrast($background-color);
|
||||
background-color: darken($background-color, 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
11
odoo-bringout-oca-ocb-crm/crm/static/src/scss/crm_team.scss
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.o_crm_team_form_view {
|
||||
/*
|
||||
* Used to conditionnaly add a css class on a record field.
|
||||
* Add text-warning on the element with class o_crm_lead_month_assignment
|
||||
* only if the element with o_crm_lead_all_assigned_month_exceeded is visible
|
||||
* in the template.
|
||||
*/
|
||||
div.o_crm_lead_all_assigned_month_exceeded + .o_crm_lead_month_assignment {
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.o_crm_team_member_kanban {
|
||||
.o_crm_team_member_kanban .o_kanban_renderer {
|
||||
.o_member_assignment div.oe_gauge {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { useComponent } = owl;
|
||||
|
||||
export async function checkRainbowmanMessage(orm, effect, recordId) {
|
||||
const message = await orm.call("crm.lead", "get_rainbowman_message", [[recordId]]);
|
||||
if (message) {
|
||||
|
|
@ -13,10 +7,3 @@ export async function checkRainbowmanMessage(orm, effect, recordId) {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useCheckRainbowman() {
|
||||
const component = useComponent();
|
||||
const orm = useService("orm");
|
||||
const effect = useService("effect");
|
||||
return checkRainbowmanMessage.bind(component, orm, effect);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,10 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
|
||||
import { Record, RelationalModel } from "@web/views/basic_relational_model";
|
||||
|
||||
/**
|
||||
* This Form Controller makes sure we display a rainbowman message
|
||||
* when the stage is won, even when we click on the statusbar.
|
||||
* When the stage of a lead is changed and data are saved, we check
|
||||
* if the lead is won and if a message should be displayed to the user
|
||||
* with a rainbowman like when the user click on the button "Mark Won".
|
||||
*/
|
||||
export class CrmFormRecord extends Record {
|
||||
/**
|
||||
* Main method used when saving the record hitting the "Save" button.
|
||||
class CrmFormRecord extends formView.Model.Record {
|
||||
/**
|
||||
* override of record _save mechanism intended to affect the main form record
|
||||
* We check if the stage_id field was altered and if we need to display a rainbowman
|
||||
* message.
|
||||
*
|
||||
|
|
@ -30,60 +20,49 @@ export class CrmFormRecord extends Record {
|
|||
*
|
||||
* @override
|
||||
*/
|
||||
async save() {
|
||||
const recordID = this.__bm_handle__;
|
||||
const localData = this.model.__bm__.localData[recordID];
|
||||
const changes = localData._changes || {};
|
||||
|
||||
async _save() {
|
||||
if (this.resModel !== "crm.lead") {
|
||||
return super._save(...arguments);
|
||||
}
|
||||
let changeStage = false;
|
||||
const needsSynchronizationEmail =
|
||||
changes.partner_email_update === undefined
|
||||
? localData.data.partner_email_update // original value
|
||||
: changes.partner_email_update; // new value
|
||||
this._changes.partner_email_update === undefined
|
||||
? this._values.partner_email_update // original value
|
||||
: this._changes.partner_email_update; // new value
|
||||
|
||||
const needsSynchronizationPhone =
|
||||
changes.partner_phone_update === undefined
|
||||
? localData.data.partner_phone_update // original value
|
||||
: changes.partner_phone_update; // new value
|
||||
this._changes.partner_phone_update === undefined
|
||||
? this._values.partner_phone_update // original value
|
||||
: this._changes.partner_phone_update; // new value
|
||||
|
||||
if (
|
||||
needsSynchronizationEmail &&
|
||||
changes.email_from === undefined &&
|
||||
localData.data.email_from
|
||||
) {
|
||||
changes.email_from = localData.data.email_from;
|
||||
if (needsSynchronizationEmail && this._changes.email_from === undefined && this._values.email_from) {
|
||||
this._changes.email_from = this._values.email_from;
|
||||
}
|
||||
if (needsSynchronizationPhone && changes.phone === undefined && localData.data.phone) {
|
||||
changes.phone = localData.data.phone;
|
||||
if (needsSynchronizationPhone && this._changes.phone === undefined && this._values.phone) {
|
||||
this._changes.phone = this._values.phone;
|
||||
}
|
||||
if (!localData._changes && Object.keys(changes).length) {
|
||||
localData._changes = changes;
|
||||
|
||||
if ("stage_id" in this._changes) {
|
||||
changeStage = this._values.stage_id !== this.data.stage_id;
|
||||
}
|
||||
let changedStage = false;
|
||||
if ("stage_id" in changes && changes.stage_id) {
|
||||
const bm = this.model.__bm__;
|
||||
let oldStageId = false;
|
||||
if (bm.localData[recordID].data.stage_id) {
|
||||
oldStageId = bm.get(bm.localData[recordID].data.stage_id).data.id;
|
||||
}
|
||||
const newStageId = bm.get(bm.localData[recordID]._changes.stage_id).data.id;
|
||||
changedStage = oldStageId !== newStageId;
|
||||
|
||||
const res = await super._save(...arguments);
|
||||
if (changeStage) {
|
||||
await checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
|
||||
}
|
||||
const isSaved = await super.save(...arguments);
|
||||
if (changedStage && isSaved) {
|
||||
checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
|
||||
}
|
||||
return isSaved;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
class CrmFormModel extends RelationalModel {
|
||||
class CrmFormModel extends formView.Model {
|
||||
static Record = CrmFormRecord;
|
||||
static services = [...formView.Model.services, "effect"];
|
||||
|
||||
setup(params, services) {
|
||||
this.effect = services.effect;
|
||||
super.setup(...arguments);
|
||||
this.effect = services.effect;
|
||||
}
|
||||
}
|
||||
CrmFormModel.Record = CrmFormRecord;
|
||||
CrmFormModel.services = [...RelationalModel.services, "effect"];
|
||||
|
||||
registry.category("views").add("crm_form", {
|
||||
...formView,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
.o_lead_opportunity_form {
|
||||
.o_lead_opportunity_form_AI_switch_img {
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Component, status } from "@odoo/owl";
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { registry } from '@web/core/registry';
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
|
||||
export class CrmPlsTooltip extends Component {
|
||||
static props = {
|
||||
close: { optional: true, type: Function },
|
||||
dashArrayVals: {type: String},
|
||||
low3Data: { optional: true, type: Object },
|
||||
probability: { type: Number },
|
||||
teamName: { optional: true, type: String },
|
||||
top3Data: { optional: true, type: Object },
|
||||
};
|
||||
static template = "crm.PlsTooltip";
|
||||
}
|
||||
|
||||
|
||||
export class CrmPlsTooltipButton extends Component {
|
||||
static template = "crm.PlsTooltipButton";
|
||||
static props = {...standardWidgetProps};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.popover = usePopover(CrmPlsTooltip, {
|
||||
popoverClass: 'mt-2 me-2',
|
||||
position: "bottom-start",
|
||||
});
|
||||
}
|
||||
|
||||
async onClickPlsTooltipButton(ev) {
|
||||
const tooltipButtonEl = ev.currentTarget;
|
||||
if (this.popover.isOpen) {
|
||||
this.popover.close();
|
||||
} else {
|
||||
// Apply pending changes. They may change probability
|
||||
await this.props.record.save();
|
||||
if (status(this) === "destroyed" || !this.props.record.resId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This recomputes probability, and returns all tooltip data
|
||||
const tooltipData = await this.orm.call(
|
||||
"crm.lead",
|
||||
"prepare_pls_tooltip_data",
|
||||
[this.props.record.resId]
|
||||
);
|
||||
// Update the form
|
||||
await this.props.record.load();
|
||||
|
||||
// Hard set wheel dimensions, see o_crm_pls_tooltip_wheel in scss and xml
|
||||
const progressWheelPerimeter = 2 * Math.PI * 25;
|
||||
const progressBarDashLength = progressWheelPerimeter * tooltipData.probability / 100.0;
|
||||
const progressBarDashGap = progressWheelPerimeter - progressBarDashLength;
|
||||
let dashArrayVals = progressBarDashLength + ' ' + progressBarDashGap;
|
||||
if (localization.direction === "rtl") {
|
||||
dashArrayVals = 0 + ' ' + 0.5 * progressWheelPerimeter + ' ' + dashArrayVals;
|
||||
}
|
||||
this.popover.open(tooltipButtonEl, {
|
||||
'dashArrayVals': dashArrayVals,
|
||||
'low3Data': tooltipData.low_3_data,
|
||||
'probability': tooltipData.probability,
|
||||
'teamName': tooltipData.team_name,
|
||||
'top3Data': tooltipData.top_3_data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("view_widgets").add("pls_tooltip_button", {
|
||||
component: CrmPlsTooltipButton
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
.o_crm_pls_tooltip {
|
||||
.o_crm_pls_tooltip_wheel {
|
||||
height: 60px;
|
||||
min-width: 60px;
|
||||
font-size: 11px;
|
||||
circle {
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 30px 30px;
|
||||
stroke: $link-color;
|
||||
&.o_crm_tooltip_wheel_bg_circle {
|
||||
stroke: #ddd;
|
||||
}
|
||||
}
|
||||
> * {
|
||||
height: 60px;
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
.o_crm_pls_tooltip_section_sample {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.o_crm_pls_tooltip_icon {
|
||||
min-width: 15px;
|
||||
}
|
||||
.o_crm_pls_tooltip_footer {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_crm_pls_tooltip_button img {
|
||||
height: 1rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="crm.PlsTooltipButton">
|
||||
<a class="o_crm_pls_tooltip_button border-0 mx-1 p-0 mb-1 mb-md-0 btn btn-light"
|
||||
role="button"
|
||||
title="See AI-computed chances details"
|
||||
aria-label="See AI-computed chances details"
|
||||
t-on-click.prevent.stop="onClickPlsTooltipButton">
|
||||
<img class="m-1" role="img" src="/crm/static/src/img/pls-tooltip-ai-icon.png" alt="AI"/>
|
||||
</a>
|
||||
</t>
|
||||
|
||||
<t t-name="crm.PlsTooltip">
|
||||
<div class="o_crm_pls_tooltip py-1">
|
||||
<t t-set="isSampleData" t-value="props.probability === 0.0"/>
|
||||
<t t-set="positiveDefault" t-value="props.probability >= 50.0 && !props.top3Data?.length"/>
|
||||
<t t-set="negativeDefault" t-value="props.probability < 50.0 && !props.low3Data?.length"/>
|
||||
<div class="d-flex px-2 mb-1">
|
||||
<div class="d-inline-block me-3 o_crm_pls_tooltip_wheel position-relative">
|
||||
<svg class="position-absolute" width="60" height="60" viewBox="0 0 60 60">
|
||||
<circle class="o_crm_tooltip_wheel_bg_circle"
|
||||
cx="30" cy="30" r="25" fill="none" stroke-width="4"/>
|
||||
<circle cx="30" cy="30" r="25" fill="none" stroke-width="4"
|
||||
t-att-stroke-dasharray="props.dashArrayVals"/>
|
||||
</svg>
|
||||
<div class="position-absolute d-flex">
|
||||
<span class="m-auto fw-bolder">
|
||||
<t t-out="props.probability"/>%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-inline-block d-flex flex-column justify-content-center">
|
||||
<div t-if="isSampleData" class="text-primary fw-bold text-uppercase">sample data</div>
|
||||
<div t-if="props.teamName && !isSampleData">AI-computed chances of winning for</div>
|
||||
<div t-else="">AI-computed chances of winning</div>
|
||||
<div t-if="!isSampleData" class="fw-bolder fs-3">
|
||||
<t t-if="props.teamName" t-out="props.teamName"/>
|
||||
<t t-else="">this lead</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="positiveDefault || props.top3Data?.length"
|
||||
t-attf-class="mb-3 {{isSampleData ? 'o_crm_pls_tooltip_section_sample' : ''}}">
|
||||
<h5 class="bg-primary-subtle py-2 px-2">
|
||||
<span>Top Positives</span>
|
||||
<span t-if="isSampleData" class="text-primary fw-normal ms-2">(sample data)</span>
|
||||
</h5>
|
||||
<t t-call="crm.PlsTooltipSectionContent">
|
||||
<t t-set="iconClasses" t-value="'oi oi-arrow-up-right text-success'"/>
|
||||
<t t-set="sectionEntries" t-value="props.top3Data"/>
|
||||
<t t-set="teamName" t-value="props.teamName"/>
|
||||
<t t-set="useDefault" t-value="positiveDefault"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="negativeDefault || props.low3Data?.length"
|
||||
t-attf-class="mb-3 {{isSampleData ? 'o_crm_pls_tooltip_section_sample' : ''}}">
|
||||
<h5 class="bg-primary-subtle py-2 px-2">
|
||||
<span>Top Negatives</span>
|
||||
<span t-if="isSampleData" class="text-primary fw-normal ms-2">(sample data)</span>
|
||||
</h5>
|
||||
<t t-call="crm.PlsTooltipSectionContent">
|
||||
<t t-set="iconClasses" t-value="'oi oi-arrow-down-right text-danger'"/>
|
||||
<t t-set="sectionEntries" t-value="props.low3Data"/>
|
||||
<t t-set="teamName" t-value="props.teamName"/>
|
||||
<t t-set="useDefault" t-value="negativeDefault"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_crm_pls_tooltip_footer d-flex justify-content-between text-muted fs-xs mt-3 px-2">
|
||||
<span t-if="isSampleData" class="text-primary fw-bold">Close opportunities to get insights.</span>
|
||||
<span t-else="">Computed by Odoo AI using your data.</span>
|
||||
<a role="button" class="ms-2 btn-link"
|
||||
href="https://www.odoo.com/documentation/latest/applications/sales/crm/track_leads/lead_scoring.html#predictive-lead-scoring" target="_blank">
|
||||
<i class="fa fa-info-circle pe-1"/>Learn More
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="crm.PlsTooltipSectionContent">
|
||||
<div t-foreach="sectionEntries" t-as="group" t-key="group_index"
|
||||
t-attf-class="px-2 py-1 {{group.field === 'tag_id' ? 'd-flex align-items-center' : ''}}">
|
||||
<span t-attf-class="{{iconClasses}} o_crm_pls_tooltip_icon me-1"/>
|
||||
<t t-if="group.field === 'stage_id'">
|
||||
<span class="fw-bolder"><t t-out="group.value"/></span>
|
||||
<span class="mx-1">stage</span>
|
||||
</t>
|
||||
<t t-elif="group.field === 'tag_id'">
|
||||
<span>Tagged as</span>
|
||||
<span t-attf-class="o_tag o_tag_color_{{group.color || 0}} o_badge badge rounded-pill fw-bolder mx-1">
|
||||
<span class="o_tag_badge_text" t-out="group.value"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="['state_id', 'country_id'].includes(group.field)">
|
||||
<span>Located in</span>
|
||||
<span class="fw-bolder mx-1"><t t-out="group.value"/></span>
|
||||
</t>
|
||||
<t t-elif="group.field === 'phone_state'">
|
||||
<t t-if="group.value === 'correct'">
|
||||
<span>Has a</span>
|
||||
<span class="fw-bolder mx-1">valid phone number</span>
|
||||
</t>
|
||||
<t t-elif="group.value === 'incorrect'">
|
||||
<span>Does not have a</span>
|
||||
<span class="fw-bolder mx-1">valid phone number</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span>Does not have a</span>
|
||||
<span class="fw-bolder mx-1">phone number</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="group.field === 'email_state'">
|
||||
<t t-if="group.value === 'correct'">
|
||||
<span>Has a</span>
|
||||
<span class="fw-bolder mx-1">valid email address</span>
|
||||
</t>
|
||||
<t t-elif="group.value === 'incorrect'">
|
||||
<span>Does not have a</span>
|
||||
<span class="fw-bolder mx-1">valid email address</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span>Does not have an</span>
|
||||
<span class="fw-bolder mx-1">email address</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="group.field === 'source_id'">
|
||||
<span>Source is</span>
|
||||
<span class="fw-bolder mx-1"><t t-out="group.value"/></span>
|
||||
</t>
|
||||
<t t-elif="group.field === 'lang_id'">
|
||||
<span>Language is</span>
|
||||
<span class="fw-bolder mx-1"><t t-out="group.value"/></span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span>Field</span><span class="mx-1"><t t-out="group.field"/></span>
|
||||
<span>has value</span><span class="fw-bolder mx-1"><t t-out="group.value"/></span>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="useDefault" class="px-2 py-1">
|
||||
<span t-attf-class="{{iconClasses}} o_crm_pls_tooltip_icon me-1"/>
|
||||
<span class="">Historic win rate</span>
|
||||
<span t-if="teamName" class="fw-bolder">
|
||||
(<span class="me-1" t-out="teamName"/><span>team</span>)
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { onWillStart } from "@odoo/owl";
|
||||
import { user } from "@web/core/user";
|
||||
import { RottingColumnProgress } from "@mail/js/rotting_mixin/rotting_column_progress";
|
||||
|
||||
export class CrmColumnProgress extends RottingColumnProgress {
|
||||
static template = "crm.ColumnProgress";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.showRecurringRevenue = false;
|
||||
|
||||
onWillStart(async () => {
|
||||
if (this.props.progressBarState.progressAttributes.recurring_revenue_sum_field) {
|
||||
this.showRecurringRevenue = await user.hasGroup("crm.group_use_recurring_revenues");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getRecurringRevenueGroupAggregate(group) {
|
||||
if (!this.showRecurringRevenue) {
|
||||
return {};
|
||||
}
|
||||
const rrField = this.props.progressBarState.progressAttributes.recurring_revenue_sum_field;
|
||||
return this.props.progressBarState.getAggregateValue(group, rrField);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="crm.ColumnProgress" t-inherit="mail.RottingColumnProgress" t-inherit-mode="primary">
|
||||
<xpath expr="//div[hasclass('o_column_progress')]" position="attributes">
|
||||
<attribute name="class" remove="w-75" add="w-50" separator=" "/>
|
||||
</xpath>
|
||||
|
||||
<AnimatedNumber position="before">
|
||||
<div class="ms-auto"/>
|
||||
</AnimatedNumber>
|
||||
|
||||
<AnimatedNumber position="attributes">
|
||||
<!--
|
||||
If the value of the standard aggregate is 0, but the value of the monthly aggregate isn't 0, false or undefined,
|
||||
we want to hide the 0 of the standard aggregate.
|
||||
-->
|
||||
<attribute name="t-if" add="!(props.aggregate.value === 0 and getRecurringRevenueGroupAggregate(props.group).value)" separator=" and "/>
|
||||
</AnimatedNumber>
|
||||
|
||||
<AnimatedNumber position="after">
|
||||
<t t-elif="props.aggregate.value === 0"><b/></t>
|
||||
|
||||
<t t-if="showRecurringRevenue">
|
||||
<t t-set="rrmAggregate" t-value="getRecurringRevenueGroupAggregate(props.group)"/>
|
||||
<AnimatedNumber
|
||||
t-if="rrmAggregate.value"
|
||||
value="rrmAggregate.value"
|
||||
title="rrmAggregate.title"
|
||||
duration="1000"
|
||||
currencies="[rrmAggregate.currency]"
|
||||
animationClass="'o_animated_grow_huge'"
|
||||
>
|
||||
<t t-set-slot="prefix" t-if="props.aggregate.value != 0">
|
||||
<strong class="me-1">+</strong>
|
||||
</t>
|
||||
</AnimatedNumber>
|
||||
<b t-if="!props.aggregate.value" class="ps-1">MRR</b>
|
||||
</t>
|
||||
</AnimatedNumber>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { KanbanArchParser } from "@web/views/kanban/kanban_arch_parser";
|
||||
import { extractAttributes } from "@web/core/utils/xml";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,14 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { KanbanModel } from "@web/views/kanban/kanban_model";
|
||||
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
|
||||
import { RelationalModel } from "@web/model/relational_model/relational_model";
|
||||
|
||||
export class CrmKanbanModel extends KanbanModel {
|
||||
setup(params, { orm, effect }) {
|
||||
export class CrmKanbanModel extends RelationalModel {
|
||||
setup(params, { effect }) {
|
||||
super.setup(...arguments);
|
||||
this.ormService = orm;
|
||||
this.effect = effect;
|
||||
}
|
||||
}
|
||||
|
||||
export class CrmKanbanDynamicGroupList extends CrmKanbanModel.DynamicGroupList {
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Add the RRM field to the sumfields to fetch in read_group calls
|
||||
*/
|
||||
get sumFields() {
|
||||
const result = super.sumFields;
|
||||
if (this.model.progressAttributes.recurring_revenue_sum_field) {
|
||||
result.push(this.model.progressAttributes.recurring_revenue_sum_field.name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class CrmKanbanDynamicGroupList extends RelationalModel.DynamicGroupList {
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
|
|
@ -32,10 +16,7 @@ export class CrmKanbanDynamicGroupList extends CrmKanbanModel.DynamicGroupList {
|
|||
* a rainbowman message if that's the case.
|
||||
*/
|
||||
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
|
||||
const succeeded = await super.moveRecord(...arguments);
|
||||
if (!succeeded) {
|
||||
return;
|
||||
}
|
||||
await super.moveRecord(...arguments);
|
||||
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
|
||||
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
|
||||
if (
|
||||
|
|
@ -45,30 +26,10 @@ export class CrmKanbanDynamicGroupList extends CrmKanbanModel.DynamicGroupList {
|
|||
sourceGroup.groupByField.name === "stage_id"
|
||||
) {
|
||||
const record = targetGroup.list.records.find((r) => r.id === dataRecordId);
|
||||
await checkRainbowmanMessage(this.model.ormService, this.model.effect, record.resId);
|
||||
await checkRainbowmanMessage(this.model.orm, this.model.effect, record.resId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CrmKanbanGroup extends CrmKanbanModel.Group {
|
||||
/**
|
||||
* This is called whenever the progress bar is changed, for example
|
||||
* when filtering on a certain stage from the progress bar and is meant
|
||||
* to update `sum_field` aggregated value.
|
||||
* We also want to update the recurring revenue aggregate.
|
||||
*/
|
||||
updateAggregates(groupData) {
|
||||
if (this.model.progressAttributes.recurring_revenue_sum_field) {
|
||||
const rrField = this.model.progressAttributes.recurring_revenue_sum_field;
|
||||
const group = groupData.find(g => this.valueEquals(g[this.groupByField.name]));
|
||||
if (rrField) {
|
||||
this.aggregates[rrField.name] = group ? group[rrField.name] : 0;
|
||||
}
|
||||
}
|
||||
return super.updateAggregates(...arguments);
|
||||
}
|
||||
}
|
||||
|
||||
CrmKanbanModel.DynamicGroupList = CrmKanbanDynamicGroupList;
|
||||
CrmKanbanModel.Group = CrmKanbanGroup;
|
||||
CrmKanbanModel.services = [...KanbanModel.services, "effect", "orm"];
|
||||
CrmKanbanModel.services = [...RelationalModel.services, "effect"];
|
||||
|
|
|
|||
|
|
@ -1,33 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
import { CrmColumnProgress } from "./crm_column_progress";
|
||||
import { RottingKanbanRecord } from "@mail/js/rotting_mixin/rotting_kanban_record";
|
||||
import { RottingKanbanHeader } from "@mail/js/rotting_mixin/rotting_kanban_header";
|
||||
import { RottingKanbanRenderer } from "@mail/js/rotting_mixin/rotting_kanban_renderer";
|
||||
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { session } from "@web/session";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { onWillStart } = owl;
|
||||
|
||||
export class CrmKanbanRenderer extends KanbanRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.user = useService("user");
|
||||
|
||||
this.showRecurringRevenue = false;
|
||||
onWillStart(async () => {
|
||||
this.showRecurringRevenue =
|
||||
this.props.list.model.progressAttributes.recurring_revenue_sum_field &&
|
||||
(await this.user.hasGroup("crm.group_use_recurring_revenues"));
|
||||
});
|
||||
}
|
||||
|
||||
getRecurringRevenueGroupAggregate(group) {
|
||||
const rrField = this.props.list.model.progressAttributes.recurring_revenue_sum_field;
|
||||
const value = group.getAggregates(rrField.name);
|
||||
const title = rrField.string || this.env._t("Count");
|
||||
let currency = false;
|
||||
if (value && rrField.currency_field) {
|
||||
currency = session.currencies[session.company_currency_id];
|
||||
}
|
||||
return { value, currency, title };
|
||||
}
|
||||
class CrmKanbanHeader extends RottingKanbanHeader {
|
||||
static components = {
|
||||
...RottingKanbanHeader.components,
|
||||
ColumnProgress: CrmColumnProgress,
|
||||
};
|
||||
}
|
||||
|
||||
export class CrmKanbanRenderer extends RottingKanbanRenderer {
|
||||
static components = {
|
||||
...RottingKanbanRenderer.components,
|
||||
KanbanHeader: CrmKanbanHeader,
|
||||
KanbanRecord: RottingKanbanRecord,
|
||||
};
|
||||
}
|
||||
CrmKanbanRenderer.template = "crm.CrmKanbanRenderer";
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="crm.CrmKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//div[hasclass('o_kanban_counter_progress')]" position="attributes">
|
||||
<attribute name="class" remove="w-75" add="w-50" separator=" "/>
|
||||
</xpath>
|
||||
<KanbanAnimatedNumber position="after">
|
||||
<t t-if="showRecurringRevenue">
|
||||
<t t-set="rrmAggregate" t-value="getRecurringRevenueGroupAggregate(group)"/>
|
||||
<KanbanAnimatedNumber
|
||||
value="rrmAggregate.value"
|
||||
title="rrmAggregate.title"
|
||||
duration="1000"
|
||||
currency="aggregate.currency"
|
||||
animationClass="'o_kanban_grow_huge'"
|
||||
>
|
||||
<t t-set-slot="prefix">
|
||||
<strong>+</strong>
|
||||
</t>
|
||||
</KanbanAnimatedNumber>
|
||||
</t>
|
||||
</KanbanAnimatedNumber>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,16 +1,23 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
|
||||
import { CrmKanbanArchParser } from "@crm/views/crm_kanban/crm_kanban_arch_parser";
|
||||
import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
|
||||
import { rottingKanbanView } from "@mail/js/rotting_mixin/rotting_kanban_view";
|
||||
|
||||
export const crmKanbanView = {
|
||||
...kanbanView,
|
||||
...rottingKanbanView,
|
||||
ArchParser: CrmKanbanArchParser,
|
||||
// Makes it easier to patch
|
||||
Controller: class extends kanbanView.Controller {},
|
||||
Controller: class extends rottingKanbanView.Controller {
|
||||
get progressBarAggregateFields() {
|
||||
const res = super.progressBarAggregateFields;
|
||||
const progressAttributes = this.props.archInfo.progressAttributes;
|
||||
if (progressAttributes && progressAttributes.recurring_revenue_sum_field) {
|
||||
res.push(progressAttributes.recurring_revenue_sum_field);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
},
|
||||
Model: CrmKanbanModel,
|
||||
Renderer: CrmKanbanRenderer,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
serializeDate,
|
||||
serializeDateTime,
|
||||
} from "@web/core/l10n/dates";
|
||||
|
||||
/**
|
||||
* Configuration depending on the granularity:
|
||||
* @param {function} startOf function to get the start moment of the period from a moment
|
||||
* Configuration depending on the granularity, using Luxon DateTime objects:
|
||||
* @param {function} startOf function to get a DateTime at the beginning of a period
|
||||
* from another DateTime.
|
||||
* @param {int} cycle amount of 'granularity' periods constituting a cycle. The cycle duration
|
||||
* is arbitrary for each granularity:
|
||||
* cycle --- granularity
|
||||
|
|
@ -15,35 +18,35 @@ import { registry } from "@web/core/registry";
|
|||
* 1 year month
|
||||
* 1 year quarter
|
||||
* 1 year year # we are not using a greater time period in Odoo (yet)
|
||||
* @param {int} cyclePos function to get the position (index) in the cycle from a moment.
|
||||
* {1} is the first index. {+1} is used for functions which have an index
|
||||
* @param {int} cyclePos function to get the position (index) in the cycle from a DateTime.
|
||||
* {1} is the first index. {+1} is used for properties which have an index
|
||||
* starting from 0, to standardize between granularities.
|
||||
*/
|
||||
export const GRANULARITY_TABLE = {
|
||||
hour: {
|
||||
startOf: (x) => x.startOf("hour"),
|
||||
cycle: 24,
|
||||
cyclePos: (x) => x.hour() + 1,
|
||||
cyclePos: (x) => x.hour + 1,
|
||||
},
|
||||
day: {
|
||||
startOf: (x) => x.startOf("day"),
|
||||
cycle: 7,
|
||||
cyclePos: (x) => x.isoWeekday(),
|
||||
cyclePos: (x) => x.weekday,
|
||||
},
|
||||
week: {
|
||||
startOf: (x) => x.startOf("isoWeek"),
|
||||
startOf: (x) => x.startOf("week"),
|
||||
cycle: 1,
|
||||
cyclePos: (x) => 1,
|
||||
},
|
||||
month: {
|
||||
startOf: (x) => x.startOf("month"),
|
||||
cycle: 12,
|
||||
cyclePos: (x) => x.month() + 1,
|
||||
cyclePos: (x) => x.month,
|
||||
},
|
||||
quarter: {
|
||||
startOf: (x) => x.startOf("quarter"),
|
||||
cycle: 4,
|
||||
cyclePos: (x) => x.quarter(),
|
||||
cyclePos: (x) => x.quarter,
|
||||
},
|
||||
year: {
|
||||
startOf: (x) => x.startOf("year"),
|
||||
|
|
@ -52,23 +55,6 @@ export const GRANULARITY_TABLE = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* configuration depending on the time type:
|
||||
* @param {string} format moment format to display this type as a string
|
||||
* @param {string} minGranularity granularity of the smallest time interval used in Odoo for this
|
||||
* type
|
||||
*/
|
||||
export const FIELD_TYPE_TABLE = {
|
||||
date: {
|
||||
format: "YYYY-MM-DD",
|
||||
minGranularity: "day",
|
||||
},
|
||||
datetime: {
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
minGranularity: "second",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* fill_temporal period:
|
||||
* Represents a specific date/time range for a specific model, field and granularity.
|
||||
|
|
@ -102,15 +88,19 @@ export class FillTemporalPeriod {
|
|||
this._computeEnd();
|
||||
}
|
||||
/**
|
||||
* Compute the moment for the start of the period containing "now"
|
||||
* Compute this.start: the DateTime for the start of the period containing
|
||||
* the current time ("now").
|
||||
* i.e. 2020-10-01 13:43:17 -> the current "hour" DateTime started at:
|
||||
* 2020-10-01 13:00:00
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_computeStart() {
|
||||
this.start = GRANULARITY_TABLE[this.granularity].startOf(moment());
|
||||
this.start = GRANULARITY_TABLE[this.granularity].startOf(luxon.DateTime.now());
|
||||
}
|
||||
/**
|
||||
* Compute the moment for the end of the fill_temporal period. This bound is exclusive.
|
||||
* Compute this.end: the DateTime for the end of the fill_temporal period.
|
||||
* This bound is exclusive.
|
||||
* The fill_temporal period is the number of [granularity] from [start] to the end of the
|
||||
* [cycle] reached after adding [minGroups]
|
||||
* i.e. we are in october 2020 :
|
||||
|
|
@ -125,51 +115,49 @@ export class FillTemporalPeriod {
|
|||
_computeEnd() {
|
||||
const cycle = GRANULARITY_TABLE[this.granularity].cycle;
|
||||
const cyclePos = GRANULARITY_TABLE[this.granularity].cyclePos(this.start);
|
||||
/**
|
||||
* fillTemporalPeriod formula explanation :
|
||||
* We want to know how many steps need to be taken from the current position until the end
|
||||
* of the cycle reached after guaranteeing minGroups positions. Let's call this cycle (C).
|
||||
*
|
||||
* (1) compute the steps needed to reach the last position of the current cycle, from the
|
||||
* current position:
|
||||
* {cycle - cyclePos}
|
||||
*
|
||||
* (2) ignore {minGroups - 1} steps from the position reached in (1). Now, the current
|
||||
* position is somewhere in (C). One step from minGroups is reserved to reach the first
|
||||
* position after (C), hence {-1}
|
||||
*
|
||||
* (3) compute the additional steps needed to reach the last position of (C), from the
|
||||
* position reached in (2):
|
||||
* {cycle - (minGroups - 1) % cycle}
|
||||
*
|
||||
* (4) combine (1) and (3), the sum should not be greater than a full cycle (-> truncate):
|
||||
* {(2 * cycle - (minGroups - 1) % cycle - cyclePos) % cycle}
|
||||
*
|
||||
* (5) add minGroups!
|
||||
*/
|
||||
// fillTemporalPeriod formula explanation :
|
||||
// We want to know how many steps need to be taken from the current position until the end
|
||||
// of the cycle reached after guaranteeing minGroups positions. Let's call this cycle (C).
|
||||
//
|
||||
// (1) compute the steps needed to reach the last position of the current cycle, from the
|
||||
// current position:
|
||||
// {cycle - cyclePos}
|
||||
//
|
||||
// (2) ignore {minGroups - 1} steps from the position reached in (1). Now, the current
|
||||
// position is somewhere in (C). One step from minGroups is reserved to reach the first
|
||||
// position after (C), hence {-1}
|
||||
//
|
||||
// (3) compute the additional steps needed to reach the last position of (C), from the
|
||||
// position reached in (2):
|
||||
// {cycle - (minGroups - 1) % cycle}
|
||||
//
|
||||
// (4) combine (1) and (3), the sum should not be greater than a full cycle (-> truncate):
|
||||
// {(2 * cycle - (minGroups - 1) % cycle - cyclePos) % cycle}
|
||||
//
|
||||
// (5) add minGroups!
|
||||
const fillTemporalPeriod = ((2 * cycle - ((this.minGroups - 1) % cycle) - cyclePos) % cycle) + this.minGroups;
|
||||
this.end = moment(this.start).add(fillTemporalPeriod, `${this.granularity}s`);
|
||||
this.end = this.start.plus({[`${this.granularity}s`]: fillTemporalPeriod});
|
||||
this.computedEnd = true;
|
||||
}
|
||||
/**
|
||||
* The server needs a date/time in UTC, but we don't want a day shift in case
|
||||
* of dates, even if the date is not in UTC
|
||||
*
|
||||
* @param {moment} bound the moment to be formatted (this.start or this.end)
|
||||
* @param {DateTime} bound the DateTime to be formatted (this.start or this.end)
|
||||
*/
|
||||
_getFormattedServerDate(bound) {
|
||||
if (bound.isUTC() || this.field.type === "date") {
|
||||
return bound.clone().locale("en").format(FIELD_TYPE_TABLE[this.field.type].format);
|
||||
if (this.field.type === "date") {
|
||||
return serializeDate(bound);
|
||||
} else {
|
||||
return moment.utc(bound).locale("en").format(FIELD_TYPE_TABLE[this.field.type].format);
|
||||
return serializeDateTime(bound);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Object} configuration
|
||||
* @param {Array[]} [domain]
|
||||
* @param {boolean} [forceStartBound=true] whether this.start moment must be used as a domain
|
||||
* @param {boolean} [forceStartBound=true] whether this.start DateTime must be used as a domain
|
||||
* constraint to limit read_group results or not
|
||||
* @param {boolean} [forceEndBound=true] whether this.end moment must be used as a domain
|
||||
* @param {boolean} [forceEndBound=true] whether this.end DateTime must be used as a domain
|
||||
* constraint to limit read_group results or not
|
||||
* @returns {Array[]} new domain
|
||||
*/
|
||||
|
|
@ -210,9 +198,9 @@ export class FillTemporalPeriod {
|
|||
fillTemporal.fill_from = this._getFormattedServerDate(this.start);
|
||||
}
|
||||
if (forceFillingTo) {
|
||||
fillTemporal.fill_to = this._getFormattedServerDate(
|
||||
moment(this.end).subtract(1, FIELD_TYPE_TABLE[this.field.type].minGranularity)
|
||||
);
|
||||
// smallest time interval used in Odoo for the current date type
|
||||
const minGranularity = this.field.type === "date" ? "days" : "seconds";
|
||||
fillTemporal.fill_to = this._getFormattedServerDate(this.end.minus({[minGranularity]: 1}));
|
||||
}
|
||||
context = { ...context, fill_temporal: fillTemporal };
|
||||
return context;
|
||||
|
|
@ -225,29 +213,29 @@ export class FillTemporalPeriod {
|
|||
this.minGroups = minGroups || 1;
|
||||
}
|
||||
/**
|
||||
* sets the end of the period to the desired moment. It must be greater
|
||||
* sets the end of the period to the desired DateTime. It must be greater
|
||||
* than start. Changes the default behavior of getContext forceFillingTo
|
||||
* (becomes true instead of false)
|
||||
*
|
||||
* @param {moment} end
|
||||
* @param {DateTime} end
|
||||
*/
|
||||
setEnd(end) {
|
||||
this.end = moment.max(this.start, end);
|
||||
this.end = luxon.DateTime.max(this.start, end);
|
||||
this.computedEnd = false;
|
||||
}
|
||||
/**
|
||||
* sets the start of the period to the desired moment. It must be smaller than end
|
||||
* sets the start of the period to the desired DateTime. It must be smaller than end
|
||||
*
|
||||
* @param {moment} start
|
||||
* @param {DateTime} start
|
||||
*/
|
||||
setStart(start) {
|
||||
this.start = moment.min(this.end, start);
|
||||
this.start = luxon.DateTime.min(this.end, start);
|
||||
}
|
||||
/**
|
||||
* Adds one "granularity" period to [this.end], to expand the current fill_temporal period
|
||||
*/
|
||||
expand() {
|
||||
this.setEnd(this.end.add(1, `${this.granularity}s`));
|
||||
this.setEnd(this.end.plus({[`${this.granularity}s`]: 1}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { INTERVAL_OPTIONS } from "@web/search/utils/dates";
|
||||
import { KanbanColumnQuickCreate } from "@web/views/kanban/kanban_column_quick_create";
|
||||
|
||||
|
|
@ -12,7 +9,7 @@ export class ForecastKanbanColumnQuickCreate extends KanbanColumnQuickCreate {
|
|||
get relatedFieldName() {
|
||||
const { granularity = "month" } = this.props.groupByField;
|
||||
const { description } = INTERVAL_OPTIONS[granularity];
|
||||
return sprintf(_t("Add next %s"), description.toLocaleLowerCase());
|
||||
return _t("next %s", description.toLocaleLowerCase());
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { crmKanbanView } from "@crm/views/crm_kanban/crm_kanban_view";
|
||||
|
||||
export class ForecastKanbanController extends crmKanbanView.Controller {
|
||||
isQuickCreateField(field) {
|
||||
return super.isQuickCreateField(...arguments) || (field && field.name === "date_deadline");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +1,69 @@
|
|||
/** @odoo-module **/
|
||||
import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
|
||||
|
||||
import { KanbanModel } from "@web/views/kanban/kanban_model";
|
||||
|
||||
export class ForecastKanbanModel extends KanbanModel {
|
||||
export class ForecastKanbanModel extends CrmKanbanModel {
|
||||
setup(params, { fillTemporalService }) {
|
||||
super.setup(...arguments);
|
||||
this.fillTemporalService = fillTemporalService;
|
||||
}
|
||||
}
|
||||
|
||||
export class ForecastKanbanDynamicGroupList extends ForecastKanbanModel.DynamicGroupList {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup(params, state) {
|
||||
super.setup(...arguments);
|
||||
// Detect a reload vs an initial load, initial load should forceRecompute
|
||||
this.forceNextRecompute = !state.groups;
|
||||
this.forceNextRecompute = !params.state?.groups;
|
||||
this.originalDomain = null;
|
||||
this.fillTemporalDomain = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Add fill_temporal context keys to the context before loading the groups.
|
||||
*/
|
||||
get context() {
|
||||
const context = super.context;
|
||||
if (!this.isForecastGroupBy()) {
|
||||
return context;
|
||||
async _webReadGroup(config) {
|
||||
if (this.isForecastGroupBy(config)) {
|
||||
config.context = this.fillTemporalPeriod(config).getContext({
|
||||
context: config.context,
|
||||
});
|
||||
// Domain leaves added by the fillTemporalPeriod should be replaced
|
||||
// between 2 _webReadGroup calls, not added on top of each other.
|
||||
// Keep track of the modified domain, and if encountered in the
|
||||
// future, modify the original domain instead. It is not robust
|
||||
// against external modification of `config.domain`, but currently
|
||||
// there are only replacements except this case.
|
||||
if (!this.originalDomain || this.fillTemporalDomain !== config.domain) {
|
||||
this.originalDomain = config.domain || [];
|
||||
}
|
||||
this.fillTemporalDomain = this.fillTemporalPeriod(config).getDomain({
|
||||
domain: this.originalDomain,
|
||||
forceStartBound: false,
|
||||
});
|
||||
config.domain = this.fillTemporalDomain;
|
||||
}
|
||||
return this.fillTemporalPeriod.getContext({ context });
|
||||
return super._webReadGroup(...arguments);
|
||||
}
|
||||
|
||||
async _loadGroupedList(config) {
|
||||
const res = await super._loadGroupedList(...arguments);
|
||||
if (this.isForecastGroupBy(config)) {
|
||||
const lastGroup = res.groups.filter((grp) => grp.value).slice(-1)[0];
|
||||
if (lastGroup) {
|
||||
this.fillTemporalPeriod(config).setEnd(lastGroup.range.to);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Boolean} true if the view is grouped by the forecast_field
|
||||
*/
|
||||
isForecastGroupBy(config) {
|
||||
const forecastField = config.context.forecast_field;
|
||||
const name = config.groupBy[0].split(":")[0];
|
||||
return forecastField && forecastField === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* return {FillTemporalPeriod} current fillTemporalPeriod according to group by state
|
||||
*/
|
||||
get fillTemporalPeriod() {
|
||||
const context = super.context;
|
||||
const minGroups = (context.fill_temporal && context.fill_temporal.min_groups) || undefined;
|
||||
const { name, type, granularity } = this.groupByField;
|
||||
fillTemporalPeriod(config) {
|
||||
const [groupByFieldName, granularity] = config.groupBy[0].split(":");
|
||||
const groupByField = config.fields[groupByFieldName];
|
||||
const minGroups = (config.context.fill_temporal && config.context.fill_temporal.min_groups) || undefined;
|
||||
const { name, type } = groupByField;
|
||||
const forceRecompute = this.forceNextRecompute;
|
||||
this.forceNextRecompute = false;
|
||||
return this.model.fillTemporalService.getFillTemporalPeriod({
|
||||
modelName: this.resModel,
|
||||
return this.fillTemporalService.getFillTemporalPeriod({
|
||||
modelName: config.resModel,
|
||||
field: {
|
||||
name,
|
||||
type,
|
||||
|
|
@ -52,53 +73,6 @@ export class ForecastKanbanDynamicGroupList extends ForecastKanbanModel.DynamicG
|
|||
forceRecompute,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Boolean} true if the view is grouped by the forecast_field
|
||||
*/
|
||||
isForecastGroupBy() {
|
||||
const forecastField = super.context.forecast_field;
|
||||
const { name } = this.groupByField;
|
||||
return forecastField && forecastField === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* At every __load/__reload, we have to check the range of the last group received from the
|
||||
* read_group, and update the fillTemporalPeriod from the FillTemporalService accordingly
|
||||
*/
|
||||
async load() {
|
||||
if (!this.isForecastGroupBy()) {
|
||||
return super.load(...arguments);
|
||||
}
|
||||
const result = await super.load(...arguments);
|
||||
const lastGroup = this.groups.filter((grp) => grp.value).slice(-1)[0];
|
||||
if (lastGroup) {
|
||||
this.fillTemporalPeriod.setEnd(moment.utc(lastGroup.range[this.groupBy[0]].to));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Applies the forecast logic to the domain and context if needed before the read_group.
|
||||
*/
|
||||
async _loadGroups() {
|
||||
if (!this.isForecastGroupBy()) {
|
||||
return super._loadGroups(...arguments);
|
||||
}
|
||||
const previousDomain = this.domain;
|
||||
this.domain = this.fillTemporalPeriod.getDomain({
|
||||
domain: this.domain,
|
||||
forceStartBound: false,
|
||||
});
|
||||
const result = await super._loadGroups(...arguments);
|
||||
this.domain = previousDomain;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
ForecastKanbanModel.services = [...KanbanModel.services, "fillTemporalService"];
|
||||
ForecastKanbanModel.DynamicGroupList = ForecastKanbanDynamicGroupList;
|
||||
ForecastKanbanModel.services = [...CrmKanbanModel.services, "fillTemporalService"];
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { ForecastKanbanColumnQuickCreate } from "@crm/views/forecast_kanban/forecast_kanban_column_quick_create";
|
||||
|
||||
export class ForecastKanbanRenderer extends KanbanRenderer {
|
||||
export class ForecastKanbanRenderer extends CrmKanbanRenderer {
|
||||
static template = "crm.ForecastKanbanRenderer";
|
||||
static components = {
|
||||
...CrmKanbanRenderer.components,
|
||||
ForecastKanbanColumnQuickCreate,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.fillTemporalService = useService("fillTemporalService");
|
||||
|
|
@ -25,6 +29,10 @@ export class ForecastKanbanRenderer extends KanbanRenderer {
|
|||
);
|
||||
}
|
||||
|
||||
isMovableField(field) {
|
||||
return super.isMovableField(...arguments) || field.name === "date_deadline";
|
||||
}
|
||||
|
||||
async addForecastColumn() {
|
||||
const { name, type, granularity } = this.props.list.groupByField;
|
||||
this.fillTemporalService
|
||||
|
|
@ -37,13 +45,6 @@ export class ForecastKanbanRenderer extends KanbanRenderer {
|
|||
granularity: granularity || "month",
|
||||
})
|
||||
.expand();
|
||||
await this.props.list.model.root.load();
|
||||
this.props.list.model.notify();
|
||||
await this.props.list.load();
|
||||
}
|
||||
}
|
||||
|
||||
ForecastKanbanRenderer.template = "crm.ForecastKanbanRenderer";
|
||||
ForecastKanbanRenderer.components = {
|
||||
...KanbanRenderer.components,
|
||||
ForecastKanbanColumnQuickCreate,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="crm.ForecastKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
|
||||
<t t-name="crm.ForecastKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
|
||||
<KanbanColumnQuickCreate position="replace">
|
||||
<t t-if="isGroupedByForecastField()">
|
||||
<ForecastKanbanColumnQuickCreate
|
||||
folded="true"
|
||||
onFoldChange="() => {}"
|
||||
onValidate.bind="addForecastColumn"
|
||||
exampleData="exampleData"
|
||||
groupByField="props.list.groupByField"
|
||||
/>
|
||||
</t>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { ForecastKanbanController } from "@crm/views/forecast_kanban/forecast_kanban_controller";
|
||||
import { CrmKanbanArchParser } from "@crm/views/crm_kanban/crm_kanban_arch_parser";
|
||||
import { ForecastKanbanModel } from "@crm/views/forecast_kanban/forecast_kanban_model";
|
||||
import { ForecastKanbanRenderer } from "@crm/views/forecast_kanban/forecast_kanban_renderer";
|
||||
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
|
||||
import { ForecastKanbanModel } from "@crm/views/forecast_kanban/forecast_kanban_model";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
|
||||
export const forecastKanbanView = {
|
||||
...kanbanView,
|
||||
ArchParser: CrmKanbanArchParser,
|
||||
Model: ForecastKanbanModel,
|
||||
Controller: ForecastKanbanController,
|
||||
Renderer: ForecastKanbanRenderer,
|
||||
SearchModel: ForecastSearchModel,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { pivotView } from "@web/views/pivot/pivot_view";
|
||||
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { makeContext } from "@web/core/context";
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
import {
|
||||
serializeDate,
|
||||
serializeDateTime,
|
||||
} from "@web/core/l10n/dates";
|
||||
|
||||
/**
|
||||
* This is the conversion of ForecastModelExtension. See there for more
|
||||
* explanations of what is done here.
|
||||
*/
|
||||
const DATE_FORMAT = {
|
||||
datetime: "YYYY-MM-DD HH:mm:ss",
|
||||
date: "YYYY-MM-DD",
|
||||
};
|
||||
|
||||
export class ForecastSearchModel extends SearchModel {
|
||||
/**
|
||||
|
|
@ -28,34 +26,22 @@ export class ForecastSearchModel extends SearchModel {
|
|||
/**
|
||||
* @override
|
||||
*/
|
||||
_getDomain(params = {}) {
|
||||
const domain = super._getDomain(...arguments);
|
||||
const forecastField = this.globalContext.forecast_field;
|
||||
if (!forecastField) {
|
||||
return domain;
|
||||
_getSearchItemDomain(activeItem) {
|
||||
let domain = super._getSearchItemDomain(activeItem);
|
||||
const { searchItemId } = activeItem;
|
||||
const searchItem = this.searchItems[searchItemId];
|
||||
const context = makeContext([searchItem.context || {}]);
|
||||
if (context.forecast_filter) {
|
||||
const forecastField = this.globalContext.forecast_field;
|
||||
const forecastStart = this._getForecastStart(forecastField);
|
||||
const forecastDomain = [
|
||||
"|",
|
||||
[forecastField, "=", false],
|
||||
[forecastField, ">=", forecastStart],
|
||||
];
|
||||
domain = Domain.and([domain, forecastDomain]);
|
||||
}
|
||||
let forecastFilter = false;
|
||||
for (const queryElem of this.query) {
|
||||
const searchItem = this.searchItems[queryElem.searchItemId];
|
||||
if (searchItem.type === "filter") {
|
||||
const context = makeContext([searchItem.context || {}]);
|
||||
if (context.forecast_filter) {
|
||||
forecastFilter = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!forecastFilter) {
|
||||
return domain;
|
||||
}
|
||||
const forecastStart = this._getForecastStart(forecastField);
|
||||
const forecastDomain = [
|
||||
"|",
|
||||
[forecastField, "=", false],
|
||||
[forecastField, ">=", forecastStart],
|
||||
];
|
||||
const fullDomain = Domain.and([domain, forecastDomain]);
|
||||
return params.raw ? fullDomain : fullDomain.toList();
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,7 +52,6 @@ export class ForecastSearchModel extends SearchModel {
|
|||
_getForecastStart(forecastField) {
|
||||
if (!this.forecastStart) {
|
||||
const { type } = this.searchViewFields[forecastField];
|
||||
let startMoment;
|
||||
const groupBy = this.groupBy;
|
||||
const firstForecastGroupBy = groupBy.find((gb) => gb.includes(forecastField));
|
||||
let granularity = "month";
|
||||
|
|
@ -75,12 +60,8 @@ export class ForecastSearchModel extends SearchModel {
|
|||
} else if (groupBy.length) {
|
||||
granularity = "day";
|
||||
}
|
||||
startMoment = moment().startOf(granularity);
|
||||
if (type === "datetime") {
|
||||
startMoment = moment.utc(startMoment);
|
||||
}
|
||||
const format = DATE_FORMAT[type];
|
||||
this.forecastStart = startMoment.locale("en").format(format);
|
||||
const startDateTime = luxon.DateTime.now().startOf(granularity);
|
||||
this.forecastStart = type === "datetime" ? serializeDateTime(startDateTime) : serializeDate(startDateTime);
|
||||
}
|
||||
return this.forecastStart;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { animationFrame, Deferred, queryAllTexts } from "@odoo/hoot-dom";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { user } from "@web/core/user";
|
||||
import { AnimatedNumber } from "@web/views/view_components/animated_number";
|
||||
|
||||
class Users extends models.Model {
|
||||
name = fields.Char();
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "Dhvanil" },
|
||||
{ id: 2, name: "Trivedi" },
|
||||
];
|
||||
}
|
||||
|
||||
class Stage extends models.Model {
|
||||
_name = "crm.stage";
|
||||
|
||||
name = fields.Char();
|
||||
is_won = fields.Boolean({ string: "Is won" });
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "New" },
|
||||
{ id: 2, name: "Qualified" },
|
||||
{ id: 3, name: "Won", is_won: true },
|
||||
];
|
||||
}
|
||||
|
||||
class Lead extends models.Model {
|
||||
_name = "crm.lead";
|
||||
|
||||
name = fields.Char();
|
||||
bar = fields.Boolean();
|
||||
activity_state = fields.Char({ string: "Activity State" });
|
||||
expected_revenue = fields.Integer({ string: "Revenue", sortable: true, aggregator: "sum" });
|
||||
recurring_revenue_monthly = fields.Integer({
|
||||
string: "Recurring Revenue",
|
||||
sortable: true,
|
||||
aggregator: "sum",
|
||||
});
|
||||
stage_id = fields.Many2one({ string: "Stage", relation: "crm.stage" });
|
||||
user_id = fields.Many2one({ string: "Salesperson", relation: "users" });
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
bar: false,
|
||||
name: "Lead 1",
|
||||
activity_state: "planned",
|
||||
expected_revenue: 125,
|
||||
recurring_revenue_monthly: 5,
|
||||
stage_id: 1,
|
||||
user_id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
bar: true,
|
||||
name: "Lead 2",
|
||||
activity_state: "today",
|
||||
expected_revenue: 5,
|
||||
stage_id: 2,
|
||||
user_id: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
bar: true,
|
||||
name: "Lead 3",
|
||||
activity_state: "planned",
|
||||
expected_revenue: 13,
|
||||
recurring_revenue_monthly: 20,
|
||||
stage_id: 3,
|
||||
user_id: 1,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
bar: true,
|
||||
name: "Lead 4",
|
||||
activity_state: "today",
|
||||
expected_revenue: 4,
|
||||
stage_id: 2,
|
||||
user_id: 2,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
bar: false,
|
||||
name: "Lead 5",
|
||||
activity_state: "overdue",
|
||||
expected_revenue: 8,
|
||||
recurring_revenue_monthly: 25,
|
||||
stage_id: 3,
|
||||
user_id: 1,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
bar: true,
|
||||
name: "Lead 4",
|
||||
activity_state: "today",
|
||||
expected_revenue: 4,
|
||||
recurring_revenue_monthly: 15,
|
||||
stage_id: 1,
|
||||
user_id: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([Lead, Users, Stage]);
|
||||
defineMailModels();
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(AnimatedNumber, { enableAnimations: false });
|
||||
patchWithCleanup(user, { hasGroup: (group) => group === "crm.group_use_recurring_revenues" });
|
||||
});
|
||||
test("Progressbar: do not show sum of MRR if recurring revenues is not enabled", async () => {
|
||||
patchWithCleanup(user, { hasGroup: () => false });
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "crm.lead",
|
||||
groupBy: ["stage_id"],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="card" class="flex-row justify-content-between">
|
||||
<field name="name" class="p-2"/>
|
||||
<field name="recurring_revenue_monthly" class="p-2"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
|
||||
expect(queryAllTexts(".o_kanban_counter")).toEqual(["129", "9", "21"], {
|
||||
message: "counter should not display recurring_revenue_monthly content",
|
||||
});
|
||||
});
|
||||
|
||||
test("Progressbar: ensure correct MRR sum is displayed if recurring revenues is enabled", async () => {
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "crm.lead",
|
||||
groupBy: ["stage_id"],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="card" class="flex-row justify-content-between">
|
||||
<field name="name" class="p-2"/>
|
||||
<field name="recurring_revenue_monthly" class="p-2"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
|
||||
// When no values are given in column it should return 0 and counts value if given
|
||||
// MRR=0 shouldn't be displayed, however.
|
||||
expect(queryAllTexts(".o_kanban_counter")).toEqual(["129\n+20", "9", "21\n+45"], {
|
||||
message: "counter should display the sum of recurring_revenue_monthly values",
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("Progressbar: ensure correct MRR updation after state change", async () => {
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "crm.lead",
|
||||
groupBy: ["bar"],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="card" class="flex-row justify-content-between">
|
||||
<field name="name" class="p-2"/>
|
||||
<field name="expected_revenue" class="p-2"/>
|
||||
<field name="recurring_revenue_monthly" class="p-2"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
|
||||
//MRR before state change
|
||||
expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
|
||||
["+30", "+35"],
|
||||
{
|
||||
message: "counter should display the sum of recurring_revenue_monthly values",
|
||||
}
|
||||
);
|
||||
|
||||
// Drag the first kanban record from 1st column to the top of the last column
|
||||
await contains(".o_kanban_record:first").dragAndDrop(".o_kanban_record:last");
|
||||
|
||||
//check MRR after drag&drop
|
||||
expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
|
||||
["+25", "+40"],
|
||||
{
|
||||
message:
|
||||
"counter should display the sum of recurring_revenue_monthly correctly after drag and drop",
|
||||
}
|
||||
);
|
||||
|
||||
//Activate "planned" filter on first column
|
||||
await contains('.o_kanban_group:eq(1) .progress-bar[aria-valuenow="2"]').click();
|
||||
|
||||
//check MRR after applying filter
|
||||
expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
|
||||
["+25", "+25"],
|
||||
{
|
||||
message:
|
||||
"counter should display the sum of recurring_revenue_monthly only of overdue filter in 1st column",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("Quickly drag&drop records when grouped by stage_id", async () => {
|
||||
const def = new Deferred();
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "crm.lead",
|
||||
groupBy: ["stage_id"],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="card" class="flex-row justify-content-between">
|
||||
<field name="name" class="p-2"/>
|
||||
<field name="expected_revenue" class="p-2"/>
|
||||
<field name="recurring_revenue_monthly" class="p-2"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
onRpc("web_save", async () => {
|
||||
await def;
|
||||
});
|
||||
|
||||
expect(".o_kanban_group").toHaveCount(3);
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2);
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2);
|
||||
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
|
||||
|
||||
// drag the first record of the first column on top of the second column
|
||||
await contains(".o_kanban_group:eq(0) .o_kanban_record").dragAndDrop(
|
||||
".o_kanban_group:eq(1) .o_kanban_record"
|
||||
);
|
||||
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(3);
|
||||
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
|
||||
|
||||
// drag that same record to the third column -> should have no effect as save still pending
|
||||
// (but mostly, should not crash)
|
||||
await contains(".o_kanban_group:eq(1) .o_kanban_record").dragAndDrop(
|
||||
".o_kanban_group:eq(2) .o_kanban_record"
|
||||
);
|
||||
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(3);
|
||||
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
|
||||
|
||||
def.resolve();
|
||||
await animationFrame();
|
||||
|
||||
// drag that same record to the third column
|
||||
await contains(".o_kanban_group:eq(1) .o_kanban_record").dragAndDrop(
|
||||
".o_kanban_group:eq(2) .o_kanban_record"
|
||||
);
|
||||
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2);
|
||||
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(3);
|
||||
});
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import {
|
||||
click,
|
||||
dragAndDrop,
|
||||
getFixture,
|
||||
makeDeferred,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
} from '@web/../tests/helpers/utils';
|
||||
import { KanbanAnimatedNumber } from "@web/views/kanban/kanban_animated_number";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let target;
|
||||
let serverData;
|
||||
|
||||
QUnit.module('Crm Kanban Progressbar', {
|
||||
beforeEach: function () {
|
||||
patchWithCleanup(KanbanAnimatedNumber, { enableAnimations: false });
|
||||
serverData = {
|
||||
models: {
|
||||
'res.users': {
|
||||
fields: {
|
||||
display_name: { string: 'Name', type: 'char' },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: 'Dhvanil' },
|
||||
{ id: 2, name: 'Trivedi' },
|
||||
],
|
||||
},
|
||||
'crm.stage': {
|
||||
fields: {
|
||||
display_name: { string: 'Name', type: 'char' },
|
||||
is_won: { string: 'Is won', type: 'boolean' },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: 'New' },
|
||||
{ id: 2, name: 'Qualified' },
|
||||
{ id: 3, name: 'Won', is_won: true },
|
||||
],
|
||||
},
|
||||
'crm.lead': {
|
||||
fields: {
|
||||
display_name: { string: 'Name', type: 'char' },
|
||||
bar: {string: "Bar", type: "boolean"},
|
||||
activity_state: {string: "Activity State", type: "char"},
|
||||
expected_revenue: { string: 'Revenue', type: 'integer', sortable: true },
|
||||
recurring_revenue_monthly: { string: 'Recurring Revenue', type: 'integer', sortable: true },
|
||||
stage_id: { string: 'Stage', type: 'many2one', relation: 'crm.stage' },
|
||||
user_id: { string: 'Salesperson', type: 'many2one', relation: 'res.users' },
|
||||
},
|
||||
records : [
|
||||
{ id: 1, bar: false, name: 'Lead 1', activity_state: 'planned', expected_revenue: 125, recurring_revenue_monthly: 5, stage_id: 1, user_id: 1 },
|
||||
{ id: 2, bar: true, name: 'Lead 2', activity_state: 'today', expected_revenue: 5, stage_id: 2, user_id: 2 },
|
||||
{ id: 3, bar: true, name: 'Lead 3', activity_state: 'planned', expected_revenue: 13, recurring_revenue_monthly: 20, stage_id: 3, user_id: 1 },
|
||||
{ id: 4, bar: true, name: 'Lead 4', activity_state: 'today', expected_revenue: 4, stage_id: 2, user_id: 2 },
|
||||
{ id: 5, bar: false, name: 'Lead 5', activity_state: 'overdue', expected_revenue: 8, recurring_revenue_monthly: 25, stage_id: 3, user_id: 1 },
|
||||
{ id: 6, bar: true, name: 'Lead 4', activity_state: 'today', expected_revenue: 4, recurring_revenue_monthly: 15, stage_id: 1, user_id: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
views: {},
|
||||
};
|
||||
target = getFixture();
|
||||
setupViewRegistries();
|
||||
serviceRegistry.add(
|
||||
"user",
|
||||
makeFakeUserService((group) => group === "crm.group_use_recurring_revenues"),
|
||||
{ force: true },
|
||||
);
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test("Progressbar: do not show sum of MRR if recurring revenues is not enabled", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
serverData,
|
||||
resModel: 'crm.lead',
|
||||
groupBy: ['stage_id'],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="stage_id"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="recurring_revenue_monthly"/>
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="name"/></div>
|
||||
<div><field name="recurring_revenue_monthly"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
|
||||
const reccurringRevenueNoValues = [...target.querySelectorAll('.o_crm_kanban_mrr_counter_side')].map((elem) => elem.textContent)
|
||||
assert.deepEqual(reccurringRevenueNoValues, [],
|
||||
"counter should not display recurring_revenue_monthly content");
|
||||
});
|
||||
|
||||
QUnit.test("Progressbar: ensure correct MRR sum is displayed if recurring revenues is enabled", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
serverData,
|
||||
resModel: 'crm.lead',
|
||||
groupBy: ['stage_id'],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="stage_id"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="recurring_revenue_monthly"/>
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="name"/></div>
|
||||
<div><field name="recurring_revenue_monthly"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
|
||||
const reccurringRevenueValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
|
||||
|
||||
// When no values are given in column it should return 0 and counts value if given.
|
||||
assert.deepEqual(reccurringRevenueValues, ["+20", "+0", "+45"],
|
||||
"counter should display the sum of recurring_revenue_monthly values if values are given else display 0");
|
||||
});
|
||||
|
||||
QUnit.test("Progressbar: ensure correct MRR updation after state change", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
serverData,
|
||||
resModel: 'crm.lead',
|
||||
groupBy: ['bar'],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="stage_id"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="recurring_revenue_monthly"/>
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="name"/></div>
|
||||
<div><field name="expected_revenue"/></div>
|
||||
<div><field name="recurring_revenue_monthly"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
|
||||
//MRR before state change
|
||||
let reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
|
||||
assert.deepEqual(reccurringRevenueNoValues, ['+30','+35'],
|
||||
"counter should display the sum of recurring_revenue_monthly values");
|
||||
|
||||
// Drag the first kanban record from 1st column to the top of the last column
|
||||
await dragAndDrop(
|
||||
[...target.querySelectorAll('.o_kanban_record')].shift(),
|
||||
[...target.querySelectorAll('.o_kanban_record')].pop(),
|
||||
{ position: 'bottom' }
|
||||
);
|
||||
|
||||
//check MRR after drag&drop
|
||||
reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
|
||||
assert.deepEqual(reccurringRevenueNoValues, ['+25', '+40'],
|
||||
"counter should display the sum of recurring_revenue_monthly correctly after drag and drop");
|
||||
|
||||
//Activate "planned" filter on first column
|
||||
await click(target.querySelector('.o_kanban_group:nth-child(2) .progress-bar[aria-valuenow="2"]'), null);
|
||||
|
||||
//check MRR after applying filter
|
||||
reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
|
||||
assert.deepEqual(reccurringRevenueNoValues, ['+25','+25'],
|
||||
"counter should display the sum of recurring_revenue_monthly only of overdue filter in 1st column");
|
||||
});
|
||||
|
||||
QUnit.test("Quickly drag&drop records when grouped by stage_id", async function (assert) {
|
||||
|
||||
const def = makeDeferred();
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
serverData,
|
||||
resModel: 'crm.lead',
|
||||
groupBy: ['stage_id'],
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<field name="stage_id"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="recurring_revenue_monthly"/>
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="name"/></div>
|
||||
<div><field name="expected_revenue"/></div>
|
||||
<div><field name="recurring_revenue_monthly"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
async mockRPC(route, args) {
|
||||
if (args.method === "write") {
|
||||
await def;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.containsN(target, ".o_kanban_group", 3);
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record", 2);
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 2);
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 2);
|
||||
|
||||
// drag the first record of the first column on top of the second column
|
||||
await dragAndDrop(
|
||||
target.querySelectorAll('.o_kanban_group')[0].querySelector('.o_kanban_record'),
|
||||
target.querySelectorAll('.o_kanban_group')[1].querySelector('.o_kanban_record'),
|
||||
{ position: 'top' }
|
||||
);
|
||||
|
||||
assert.containsOnce(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record");
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 3);
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 2);
|
||||
|
||||
// drag that same record to the third column -> should have no effect as save still pending
|
||||
// (but mostly, should not crash)
|
||||
await dragAndDrop(
|
||||
target.querySelectorAll('.o_kanban_group')[1].querySelector('.o_kanban_record'),
|
||||
target.querySelectorAll('.o_kanban_group')[2].querySelector('.o_kanban_record'),
|
||||
{ position: 'top' }
|
||||
);
|
||||
|
||||
assert.containsOnce(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record");
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 3);
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 2);
|
||||
|
||||
def.resolve();
|
||||
await nextTick();
|
||||
|
||||
// drag that same record to the third column
|
||||
await dragAndDrop(
|
||||
target.querySelectorAll('.o_kanban_group')[1].querySelector('.o_kanban_record'),
|
||||
target.querySelectorAll('.o_kanban_group')[2].querySelector('.o_kanban_record'),
|
||||
{ position: 'top' }
|
||||
);
|
||||
|
||||
assert.containsOnce(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record");
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 2);
|
||||
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 3);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { onRpc } from "@web/../tests/web_test_helpers";
|
||||
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||
|
||||
onRpc("get_rainbowman_message", function getRainbowmanMessage({ args, model }) {
|
||||
let message = false;
|
||||
if (model !== "crm.lead") {
|
||||
return message;
|
||||
}
|
||||
const records = this.env["crm.lead"];
|
||||
const record = records.browse(args[0])[0];
|
||||
const won_stage = this.env["crm.stage"].search_read([["is_won", "=", true]])[0];
|
||||
if (
|
||||
record.stage_id === won_stage.id &&
|
||||
record.user_id &&
|
||||
record.team_id &&
|
||||
record.planned_revenue > 0
|
||||
) {
|
||||
const now = luxon.DateTime.now();
|
||||
const query_result = {};
|
||||
// Total won
|
||||
query_result["total_won"] = records.filter(
|
||||
(r) => r.stage_id === won_stage.id && r.user_id === record.user_id
|
||||
).length;
|
||||
// Max team 30 days
|
||||
const recordsTeam30 = records.filter(
|
||||
(r) =>
|
||||
r.stage_id === won_stage.id &&
|
||||
r.team_id === record.team_id &&
|
||||
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 30)
|
||||
);
|
||||
query_result["max_team_30"] = Math.max(...recordsTeam30.map((r) => r.planned_revenue));
|
||||
// Max team 7 days
|
||||
const recordsTeam7 = records.filter(
|
||||
(r) =>
|
||||
r.stage_id === won_stage.id &&
|
||||
r.team_id === record.team_id &&
|
||||
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 7)
|
||||
);
|
||||
query_result["max_team_7"] = Math.max(...recordsTeam7.map((r) => r.planned_revenue));
|
||||
// Max User 30 days
|
||||
const recordsUser30 = records.filter(
|
||||
(r) =>
|
||||
r.stage_id === won_stage.id &&
|
||||
r.user_id === record.user_id &&
|
||||
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 30)
|
||||
);
|
||||
query_result["max_user_30"] = Math.max(...recordsUser30.map((r) => r.planned_revenue));
|
||||
// Max User 7 days
|
||||
const recordsUser7 = records.filter(
|
||||
(r) =>
|
||||
r.stage_id === won_stage.id &&
|
||||
r.user_id === record.user_id &&
|
||||
(!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 7)
|
||||
);
|
||||
query_result["max_user_7"] = Math.max(...recordsUser7.map((r) => r.planned_revenue));
|
||||
|
||||
if (query_result.total_won === 1) {
|
||||
message = "Go, go, go! Congrats for your first deal.";
|
||||
} else if (query_result.max_team_30 === record.planned_revenue) {
|
||||
message = "Boom! Team record for the past 30 days.";
|
||||
} else if (query_result.max_team_7 === record.planned_revenue) {
|
||||
message = "Yeah! Best deal out of the last 7 days for the team.";
|
||||
} else if (query_result.max_user_30 === record.planned_revenue) {
|
||||
message = "You just beat your personal record for the past 30 days.";
|
||||
} else if (query_result.max_user_7 === record.planned_revenue) {
|
||||
message = "You just beat your personal record for the past 7 days.";
|
||||
}
|
||||
}
|
||||
return message;
|
||||
});
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { serializeDateTime } from "@web/core/l10n/dates";
|
||||
|
||||
const now = luxon.DateTime.now();
|
||||
class Users extends models.Model {
|
||||
name = fields.Char();
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "Mario" },
|
||||
{ id: 2, name: "Luigi" },
|
||||
{ id: 3, name: "Link" },
|
||||
{ id: 4, name: "Zelda" },
|
||||
];
|
||||
}
|
||||
|
||||
class Team extends models.Model {
|
||||
_name = "crm.team";
|
||||
|
||||
name = fields.Char();
|
||||
member_ids = fields.Many2many({ string: "Members", relation: "users" });
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "Mushroom Kingdom", member_ids: [1, 2] },
|
||||
{ id: 2, name: "Hyrule", member_ids: [3, 4] },
|
||||
];
|
||||
}
|
||||
|
||||
class Stage extends models.Model {
|
||||
_name = "crm.stage";
|
||||
|
||||
name = fields.Char();
|
||||
is_won = fields.Boolean({ string: "Is won" });
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "Start" },
|
||||
{ id: 2, name: "Middle" },
|
||||
{ id: 3, name: "Won", is_won: true },
|
||||
];
|
||||
}
|
||||
|
||||
class Lead extends models.Model {
|
||||
_name = "crm.lead";
|
||||
|
||||
name = fields.Char();
|
||||
planned_revenue = fields.Float({ string: "Revenue" });
|
||||
date_closed = fields.Datetime({ string: "Date closed" });
|
||||
stage_id = fields.Many2one({ string: "Stage", relation: "crm.stage" });
|
||||
user_id = fields.Many2one({ string: "Salesperson", relation: "users" });
|
||||
team_id = fields.Many2one({ string: "Sales Team", relation: "crm.team" });
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Lead 1",
|
||||
planned_revenue: 5.0,
|
||||
stage_id: 1,
|
||||
team_id: 1,
|
||||
user_id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Lead 2",
|
||||
planned_revenue: 5.0,
|
||||
stage_id: 2,
|
||||
team_id: 2,
|
||||
user_id: 4,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Lead 3",
|
||||
planned_revenue: 3.0,
|
||||
stage_id: 3,
|
||||
team_id: 1,
|
||||
user_id: 1,
|
||||
date_closed: serializeDateTime(now.minus({ days: 5 })),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Lead 4",
|
||||
planned_revenue: 4.0,
|
||||
stage_id: 3,
|
||||
team_id: 2,
|
||||
user_id: 4,
|
||||
date_closed: serializeDateTime(now.minus({ days: 23 })),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Lead 5",
|
||||
planned_revenue: 7.0,
|
||||
stage_id: 3,
|
||||
team_id: 1,
|
||||
user_id: 1,
|
||||
date_closed: serializeDateTime(now.minus({ days: 20 })),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Lead 6",
|
||||
planned_revenue: 4.0,
|
||||
stage_id: 2,
|
||||
team_id: 1,
|
||||
user_id: 2,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Lead 7",
|
||||
planned_revenue: 1.8,
|
||||
stage_id: 3,
|
||||
team_id: 2,
|
||||
user_id: 3,
|
||||
date_closed: serializeDateTime(now.minus({ days: 23 })),
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Lead 8",
|
||||
planned_revenue: 1.9,
|
||||
stage_id: 1,
|
||||
team_id: 2,
|
||||
user_id: 3,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Lead 9",
|
||||
planned_revenue: 1.5,
|
||||
stage_id: 3,
|
||||
team_id: 2,
|
||||
user_id: 3,
|
||||
date_closed: serializeDateTime(now.minus({ days: 5 })),
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "Lead 10",
|
||||
planned_revenue: 1.7,
|
||||
stage_id: 2,
|
||||
team_id: 2,
|
||||
user_id: 3,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: "Lead 11",
|
||||
planned_revenue: 2.0,
|
||||
stage_id: 3,
|
||||
team_id: 2,
|
||||
user_id: 4,
|
||||
date_closed: serializeDateTime(now.minus({ days: 5 })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([Lead, Users, Stage, Team]);
|
||||
defineMailModels();
|
||||
|
||||
const testFormView = {
|
||||
arch: `
|
||||
<form js_class="crm_form">
|
||||
<header><field name="stage_id" widget="statusbar" options="{'clickable': '1'}"/></header>
|
||||
<field name="name"/>
|
||||
<field name="planned_revenue"/>
|
||||
<field name="team_id"/>
|
||||
<field name="user_id"/>
|
||||
</form>`,
|
||||
type: "form",
|
||||
resModel: "crm.lead",
|
||||
};
|
||||
const testKanbanView = {
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="name"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
resModel: "crm.lead",
|
||||
type: "kanban",
|
||||
groupBy: ["stage_id"],
|
||||
};
|
||||
|
||||
onRpc("crm.lead", "get_rainbowman_message", ({ parent }) => {
|
||||
const result = parent();
|
||||
expect.step(result || "no rainbowman");
|
||||
return result;
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("first lead won, click on statusbar on desktop", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 6,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button[data-value='3']").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("first lead won, click on statusbar on mobile", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 6,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button.dropdown-toggle").click();
|
||||
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("first lead won, click on statusbar in edit mode on desktop", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 6,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button[data-value='3']").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("first lead won, click on statusbar in edit mode on mobile", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 6,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button.dropdown-toggle").click();
|
||||
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("team record 30 days, click on statusbar on desktop", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 2,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button[data-value='3']").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Boom! Team record for the past 30 days."]);
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("team record 30 days, click on statusbar on mobile", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 2,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button.dropdown-toggle").click();
|
||||
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Boom! Team record for the past 30 days."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("team record 7 days, click on statusbar on desktop", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 1,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button[data-value='3']").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("team record 7 days, click on statusbar on mobile", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 1,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button.dropdown-toggle").click();
|
||||
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("user record 30 days, click on statusbar on desktop", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 8,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button[data-value='3']").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["You just beat your personal record for the past 30 days."]);
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("user record 30 days, click on statusbar on mobile", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 8,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button.dropdown-toggle").click();
|
||||
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["You just beat your personal record for the past 30 days."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("user record 7 days, click on statusbar on desktop", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 10,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button[data-value='3']").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["You just beat your personal record for the past 7 days."]);
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("user record 7 days, click on statusbar on mobile", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 10,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button.dropdown-toggle").click();
|
||||
await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["You just beat your personal record for the past 7 days."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("click on stage (not won) on statusbar on desktop", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 1,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button[data-value='2']").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
|
||||
expect.verifySteps(["no rainbowman"]);
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("click on stage (not won) on statusbar on mobile", async () => {
|
||||
await mountView({
|
||||
...testFormView,
|
||||
resId: 1,
|
||||
});
|
||||
|
||||
await contains(".o_statusbar_status button.dropdown-toggle").click();
|
||||
await contains(".o-dropdown--menu .dropdown-item:contains('Middle')").click();
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
|
||||
expect.verifySteps(["no rainbowman"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("first lead won, drag & drop kanban", async () => {
|
||||
await mountView({
|
||||
...testKanbanView,
|
||||
});
|
||||
|
||||
await contains(".o_kanban_record:contains(Lead 6):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("team record 30 days, drag & drop kanban", async () => {
|
||||
await mountView({
|
||||
...testKanbanView,
|
||||
});
|
||||
|
||||
await contains(".o_kanban_record:contains(Lead 2):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Boom! Team record for the past 30 days."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("team record 7 days, drag & drop kanban", async () => {
|
||||
await mountView({
|
||||
...testKanbanView,
|
||||
});
|
||||
|
||||
await contains(".o_kanban_record:contains(Lead 1):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("user record 30 days, drag & drop kanban", async () => {
|
||||
await mountView({
|
||||
...testKanbanView,
|
||||
});
|
||||
|
||||
await contains(".o_kanban_record:contains(Lead 8):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["You just beat your personal record for the past 30 days."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("user record 7 days, drag & drop kanban", async () => {
|
||||
await mountView({
|
||||
...testKanbanView,
|
||||
});
|
||||
|
||||
await contains(".o_kanban_record:contains(Lead 10):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
|
||||
expect.verifySteps(["You just beat your personal record for the past 7 days."]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("drag & drop record kanban in stage not won", async () => {
|
||||
await mountView({
|
||||
...testKanbanView,
|
||||
});
|
||||
|
||||
await contains(".o_kanban_record:contains(Lead 8):eq(0)").dragAndDrop(".o_kanban_group:eq(1)");
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
|
||||
expect.verifySteps(["no rainbowman"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("drag & drop record in kanban not grouped by stage_id", async () => {
|
||||
await mountView({
|
||||
...testKanbanView,
|
||||
groupBy: ["user_id"],
|
||||
});
|
||||
|
||||
await contains(".o_kanban_group:eq(0)").dragAndDrop(".o_kanban_group:eq(1)");
|
||||
expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
|
||||
expect.verifySteps([]); // Should never pass by the rpc
|
||||
});
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import "@crm/../tests/mock_server";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import {
|
||||
click,
|
||||
dragAndDrop,
|
||||
getFixture,
|
||||
selectDropdownItem,
|
||||
} from '@web/../tests/helpers/utils';
|
||||
import testUtils from 'web.test_utils';
|
||||
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
|
||||
import { startServer } from '@bus/../tests/helpers/mock_python_environment';
|
||||
import { start } from "@mail/../tests/helpers/test_utils";
|
||||
|
||||
addModelNamesToFetch(["crm.stage", "crm.lead"]);
|
||||
|
||||
const find = testUtils.dom.find;
|
||||
|
||||
let target;
|
||||
|
||||
function getMockRpc(assert) {
|
||||
return async (route, args, performRpc) => {
|
||||
const result = await performRpc(route, args);
|
||||
if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') {
|
||||
assert.step(result || "no rainbowman");
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
QUnit.module('Crm Rainbowman Triggers', {
|
||||
beforeEach: function () {
|
||||
const format = "YYYY-MM-DD HH:mm:ss";
|
||||
const serverData = {
|
||||
models: {
|
||||
'res.users': {
|
||||
fields: {
|
||||
display_name: { string: 'Name', type: 'char' },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: 'Mario' },
|
||||
{ id: 2, name: 'Luigi' },
|
||||
{ id: 3, name: 'Link' },
|
||||
{ id: 4, name: 'Zelda' },
|
||||
],
|
||||
},
|
||||
'crm.team': {
|
||||
fields: {
|
||||
display_name: { string: 'Name', type: 'char' },
|
||||
member_ids: { string: 'Members', type: 'many2many', relation: 'res.users' },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: 'Mushroom Kingdom', member_ids: [1, 2] },
|
||||
{ id: 2, name: 'Hyrule', member_ids: [3, 4] },
|
||||
],
|
||||
},
|
||||
'crm.stage': {
|
||||
fields: {
|
||||
display_name: { string: 'Name', type: 'char' },
|
||||
is_won: { string: 'Is won', type: 'boolean' },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: 'Start' },
|
||||
{ id: 2, name: 'Middle' },
|
||||
{ id: 3, name: 'Won', is_won: true},
|
||||
],
|
||||
},
|
||||
'crm.lead': {
|
||||
fields: {
|
||||
display_name: { string: 'Name', type: 'char' },
|
||||
planned_revenue: { string: 'Revenue', type: 'float' },
|
||||
stage_id: { string: 'Stage', type: 'many2one', relation: 'crm.stage' },
|
||||
team_id: { string: 'Sales Team', type: 'many2one', relation: 'crm.team' },
|
||||
user_id: { string: 'Salesperson', type: 'many2one', relation: 'res.users' },
|
||||
date_closed: { string: 'Date closed', type: 'datetime' },
|
||||
},
|
||||
records : [
|
||||
{ id: 1, name: 'Lead 1', planned_revenue: 5.0, stage_id: 1, team_id: 1, user_id: 1 },
|
||||
{ id: 2, name: 'Lead 2', planned_revenue: 5.0, stage_id: 2, team_id: 2, user_id: 4 },
|
||||
{ id: 3, name: 'Lead 3', planned_revenue: 3.0, stage_id: 3, team_id: 1, user_id: 1, date_closed: moment().subtract(5, 'days').format(format) },
|
||||
{ id: 4, name: 'Lead 4', planned_revenue: 4.0, stage_id: 3, team_id: 2, user_id: 4, date_closed: moment().subtract(23, 'days').format(format) },
|
||||
{ id: 5, name: 'Lead 5', planned_revenue: 7.0, stage_id: 3, team_id: 1, user_id: 1, date_closed: moment().subtract(20, 'days').format(format) },
|
||||
{ id: 6, name: 'Lead 6', planned_revenue: 4.0, stage_id: 2, team_id: 1, user_id: 2 },
|
||||
{ id: 7, name: 'Lead 7', planned_revenue: 1.8, stage_id: 3, team_id: 2, user_id: 3, date_closed: moment().subtract(23, 'days').format(format) },
|
||||
{ id: 8, name: 'Lead 8', planned_revenue: 1.9, stage_id: 1, team_id: 2, user_id: 3 },
|
||||
{ id: 9, name: 'Lead 9', planned_revenue: 1.5, stage_id: 3, team_id: 2, user_id: 3, date_closed: moment().subtract(5, 'days').format(format) },
|
||||
{ id: 10, name: 'Lead 10', planned_revenue: 1.7, stage_id: 2, team_id: 2, user_id: 3 },
|
||||
{ id: 11, name: 'Lead 11', planned_revenue: 2.0, stage_id: 3, team_id: 2, user_id: 4, date_closed: moment().subtract(5, 'days').format(format) },
|
||||
],
|
||||
},
|
||||
},
|
||||
views: {},
|
||||
};
|
||||
this.testFormView = {
|
||||
arch: `
|
||||
<form js_class="crm_form">
|
||||
<header><field name="stage_id" widget="statusbar" options="{'clickable': '1'}"/></header>
|
||||
<field name="name"/>
|
||||
<field name="planned_revenue"/>
|
||||
<field name="team_id"/>
|
||||
<field name="user_id"/>
|
||||
</form>`,
|
||||
serverData,
|
||||
type: "form",
|
||||
resModel: 'crm.lead',
|
||||
};
|
||||
this.testKanbanView = {
|
||||
arch: `
|
||||
<kanban js_class="crm_kanban">
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="name"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
serverData,
|
||||
resModel: 'crm.lead',
|
||||
type: "kanban",
|
||||
groupBy: ['stage_id'],
|
||||
};
|
||||
target = getFixture();
|
||||
setupViewRegistries();
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test("first lead won, click on statusbar", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testFormView,
|
||||
resId: 6,
|
||||
mockRPC: getMockRpc(assert),
|
||||
mode: "readonly",
|
||||
});
|
||||
|
||||
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
|
||||
assert.verifySteps(['Go, go, go! Congrats for your first deal.']);
|
||||
});
|
||||
|
||||
QUnit.test("first lead won, click on statusbar in edit mode", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testFormView,
|
||||
resId: 6,
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
|
||||
assert.verifySteps(['Go, go, go! Congrats for your first deal.']);
|
||||
});
|
||||
|
||||
QUnit.test("team record 30 days, click on statusbar", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testFormView,
|
||||
resId: 2,
|
||||
mockRPC: getMockRpc(assert),
|
||||
mode: "readonly",
|
||||
});
|
||||
|
||||
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
|
||||
assert.verifySteps(['Boom! Team record for the past 30 days.']);
|
||||
});
|
||||
|
||||
QUnit.test("team record 7 days, click on statusbar", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testFormView,
|
||||
resId: 1,
|
||||
mockRPC: getMockRpc(assert),
|
||||
mode: "readonly",
|
||||
});
|
||||
|
||||
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
|
||||
assert.verifySteps(['Yeah! Deal of the last 7 days for the team.']);
|
||||
});
|
||||
|
||||
QUnit.test("user record 30 days, click on statusbar", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testFormView,
|
||||
resId: 8,
|
||||
mockRPC: getMockRpc(assert),
|
||||
mode: "readonly",
|
||||
});
|
||||
|
||||
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
|
||||
assert.verifySteps(['You just beat your personal record for the past 30 days.']);
|
||||
});
|
||||
|
||||
QUnit.test("user record 7 days, click on statusbar", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testFormView,
|
||||
resId: 10,
|
||||
mockRPC: getMockRpc(assert),
|
||||
mode: "readonly",
|
||||
});
|
||||
|
||||
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
|
||||
assert.verifySteps(['You just beat your personal record for the past 7 days.']);
|
||||
});
|
||||
|
||||
QUnit.test("click on stage (not won) on statusbar", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testFormView,
|
||||
resId: 1,
|
||||
mockRPC: getMockRpc(assert),
|
||||
mode: "readonly",
|
||||
});
|
||||
|
||||
await click(target.querySelector(".o_statusbar_status button[data-value='2']"));
|
||||
assert.verifySteps(['no rainbowman']);
|
||||
});
|
||||
|
||||
QUnit.test("first lead won, drag & drop kanban", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await dragAndDrop(find(target, ".o_kanban_record", "Lead 6"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
|
||||
assert.verifySteps(['Go, go, go! Congrats for your first deal.']);
|
||||
});
|
||||
|
||||
QUnit.test("team record 30 days, drag & drop kanban", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await dragAndDrop(find(target, ".o_kanban_record", "Lead 2"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
|
||||
assert.verifySteps(['Boom! Team record for the past 30 days.']);
|
||||
});
|
||||
|
||||
QUnit.test("team record 7 days, drag & drop kanban", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await dragAndDrop(find(target, ".o_kanban_record", "Lead 1"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
|
||||
assert.verifySteps(['Yeah! Deal of the last 7 days for the team.']);
|
||||
});
|
||||
|
||||
QUnit.test("user record 30 days, drag & drop kanban", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await dragAndDrop(find(target, ".o_kanban_record", "Lead 8"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
|
||||
assert.verifySteps(['You just beat your personal record for the past 30 days.']);
|
||||
});
|
||||
|
||||
QUnit.test("user record 7 days, drag & drop kanban", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await dragAndDrop(find(target, ".o_kanban_record", "Lead 10"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
|
||||
assert.verifySteps(['You just beat your personal record for the past 7 days.']);
|
||||
});
|
||||
|
||||
QUnit.test("drag & drop record kanban in stage not won", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await dragAndDrop(find(target, ".o_kanban_record", "Lead 8"), target.querySelector('.o_kanban_group:nth-of-type(2)'));
|
||||
assert.verifySteps(["no rainbowman"]);
|
||||
});
|
||||
|
||||
QUnit.test("drag & drop record in kanban not grouped by stage_id", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
groupBy: ["user_id"],
|
||||
mockRPC: getMockRpc(assert),
|
||||
});
|
||||
|
||||
await dragAndDrop(target.querySelector('.o_kanban_group:nth-of-type(1)'), target.querySelector('.o_kanban_group:nth-of-type(2)'));
|
||||
assert.verifySteps([]); // Should never pass by the rpc
|
||||
});
|
||||
|
||||
QUnit.test("send a message on a new record after changing the stage", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv["crm.stage"].create({ name : "Dummy Stage", is_won: true });
|
||||
const views = {
|
||||
"crm.lead,false,form": `
|
||||
<form js_class="crm_form">
|
||||
<sheet>
|
||||
<field name="stage_id"/>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_ids" options="{'open_attachments': True}"/>
|
||||
</div>
|
||||
</form>`,
|
||||
};
|
||||
|
||||
const messageBody = "some message";
|
||||
const { insertText, openView } = await start({
|
||||
serverData: { views },
|
||||
mockRPC: function (route, args) {
|
||||
if (route === "/mail/message/post") {
|
||||
assert.deepEqual(args.post_data.body, messageBody);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await openView({
|
||||
res_model: "crm.lead",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
|
||||
await selectDropdownItem(target, "stage_id", "Dummy Stage");
|
||||
await click(target, ".o_ChatterTopbar_buttonSendMessage");
|
||||
await insertText(".o_ComposerTextInput_textarea", messageBody);
|
||||
await click(target, ".o_Composer_buttonSend");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { CrmLead } from "@crm/../tests/mock_server/mock_models/crm_lead";
|
||||
import { mailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { defineModels } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export const crmModels = {
|
||||
...mailModels,
|
||||
CrmLead
|
||||
};
|
||||
|
||||
export function defineCrmModels() {
|
||||
defineModels(crmModels);
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { expect, test } from "@odoo/hoot";
|
||||
import { queryAll, queryAllTexts, runAllTimers } from "@odoo/hoot-dom";
|
||||
import { mockDate } from "@odoo/hoot-mock";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
getService,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
quickCreateKanbanColumn,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
class Lead extends models.Model {
|
||||
_name = "crm.lead";
|
||||
|
||||
name = fields.Char();
|
||||
date_deadline = fields.Date({ string: "Expected closing" });
|
||||
}
|
||||
|
||||
defineModels([Lead]);
|
||||
defineMailModels();
|
||||
|
||||
const kanbanArch = `
|
||||
<kanban js_class="forecast_kanban">
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="name"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
`;
|
||||
|
||||
test.tags("desktop");
|
||||
test("filter out every records before the start of the current month with forecast_filter for a date field", async () => {
|
||||
// the filter is used by the forecast model extension, and applies the forecast_filter context key,
|
||||
// which adds a domain constraint on the field marked in the other context key forecast_field
|
||||
mockDate("2021-02-10 00:00:00");
|
||||
Lead._records = [
|
||||
{ id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
|
||||
{ id: 2, name: "Lead 2", date_deadline: "2021-01-20" },
|
||||
{ id: 3, name: "Lead 3", date_deadline: "2021-02-01" },
|
||||
{ id: 4, name: "Lead 4", date_deadline: "2021-02-20" },
|
||||
{ id: 5, name: "Lead 5", date_deadline: "2021-03-01" },
|
||||
{ id: 6, name: "Lead 6", date_deadline: "2021-03-20" },
|
||||
];
|
||||
|
||||
await mountView({
|
||||
arch: kanbanArch,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
|
||||
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
|
||||
</search>`,
|
||||
resModel: "crm.lead",
|
||||
type: "kanban",
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_deadline: true,
|
||||
forecast_field: "date_deadline",
|
||||
},
|
||||
groupBy: ["date_deadline"],
|
||||
});
|
||||
|
||||
// the filter is active
|
||||
expect(".o_kanban_group").toHaveCount(2);
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
|
||||
message: "1st column (February) should contain 2 record",
|
||||
});
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
|
||||
message: "2nd column (March) should contain 2 records",
|
||||
});
|
||||
|
||||
// remove the filter(
|
||||
await contains(".o_searchview_facet:contains(Forecast) .o_facet_remove").click();
|
||||
|
||||
expect(".o_kanban_group").toHaveCount(3);
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
|
||||
message: "1st column (January) should contain 2 record",
|
||||
});
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
|
||||
message: "2nd column (February) should contain 2 records",
|
||||
});
|
||||
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2, {
|
||||
message: "3nd column (March) should contain 2 records",
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("filter out every records before the start of the current month with forecast_filter for a datetime field", async () => {
|
||||
// same as for the date field
|
||||
mockDate("2021-02-10 00:00:00");
|
||||
Lead._fields.date_closed = fields.Datetime({ string: "Closed Date" });
|
||||
Lead._records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Lead 1",
|
||||
date_deadline: "2021-01-01",
|
||||
date_closed: "2021-01-01 00:00:00",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Lead 2",
|
||||
date_deadline: "2021-01-20",
|
||||
date_closed: "2021-01-20 00:00:00",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Lead 3",
|
||||
date_deadline: "2021-02-01",
|
||||
date_closed: "2021-02-01 00:00:00",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Lead 4",
|
||||
date_deadline: "2021-02-20",
|
||||
date_closed: "2021-02-20 00:00:00",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Lead 5",
|
||||
date_deadline: "2021-03-01",
|
||||
date_closed: "2021-03-01 00:00:00",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Lead 6",
|
||||
date_deadline: "2021-03-20",
|
||||
date_closed: "2021-03-20 00:00:00",
|
||||
},
|
||||
];
|
||||
await mountView({
|
||||
arch: kanbanArch,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
|
||||
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
|
||||
<filter name='groupby_date_closed' context="{'group_by':'date_closed'}"/>
|
||||
</search>`,
|
||||
resModel: "crm.lead",
|
||||
type: "kanban",
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_closed: true,
|
||||
forecast_field: "date_closed",
|
||||
},
|
||||
groupBy: ["date_closed"],
|
||||
});
|
||||
|
||||
// with the filter
|
||||
expect(".o_kanban_group").toHaveCount(2);
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
|
||||
message: "1st column (February) should contain 2 record",
|
||||
});
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
|
||||
message: "2nd column (March) should contain 2 records",
|
||||
});
|
||||
|
||||
// remove the filter
|
||||
await contains(".o_searchview_facet:contains(Forecast) .o_facet_remove").click();
|
||||
|
||||
expect(".o_kanban_group").toHaveCount(3);
|
||||
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
|
||||
message: "1st column (January) should contain 2 record",
|
||||
});
|
||||
expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
|
||||
message: "2nd column (February) should contain 2 records",
|
||||
});
|
||||
expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2, {
|
||||
message: "3nd column (March) should contain 2 records",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Since mock_server does not support fill_temporal,
|
||||
* we only check the domain and the context sent to the read_group, as well
|
||||
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
|
||||
*/
|
||||
test("Forecast on months, until the end of the year of the latest data", async () => {
|
||||
expect.assertions(3);
|
||||
mockDate("2021-10-10 00:00:00");
|
||||
|
||||
Lead._records = [
|
||||
{ id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
|
||||
{ id: 2, name: "Lead 2", date_deadline: "2021-02-01" },
|
||||
{ id: 3, name: "Lead 3", date_deadline: "2021-11-01" },
|
||||
{ id: 4, name: "Lead 4", date_deadline: "2022-01-01" },
|
||||
];
|
||||
onRpc("crm.lead", "web_read_group", ({ kwargs }) => {
|
||||
expect(kwargs.context.fill_temporal).toEqual({
|
||||
fill_from: "2021-10-01",
|
||||
min_groups: 4,
|
||||
});
|
||||
expect(kwargs.domain).toEqual([
|
||||
"&",
|
||||
"|",
|
||||
["date_deadline", "=", false],
|
||||
["date_deadline", ">=", "2021-10-01"],
|
||||
"|",
|
||||
["date_deadline", "=", false],
|
||||
["date_deadline", "<", "2023-01-01"],
|
||||
]);
|
||||
});
|
||||
await mountView({
|
||||
arch: kanbanArch,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
|
||||
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
|
||||
</search>`,
|
||||
resModel: "crm.lead",
|
||||
type: "kanban",
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_deadline: true,
|
||||
forecast_field: "date_deadline",
|
||||
},
|
||||
groupBy: ["date_deadline"],
|
||||
});
|
||||
|
||||
expect(
|
||||
getService("fillTemporalService")
|
||||
.getFillTemporalPeriod({
|
||||
modelName: "crm.lead",
|
||||
field: {
|
||||
name: "date_deadline",
|
||||
type: "date",
|
||||
},
|
||||
granularity: "month",
|
||||
})
|
||||
.end.toFormat("yyyy-MM-dd")
|
||||
).toBe("2022-02-01");
|
||||
});
|
||||
|
||||
/**
|
||||
* Since mock_server does not support fill_temporal,
|
||||
* we only check the domain and the context sent to the read_group, as well
|
||||
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
|
||||
*/
|
||||
test("Forecast on years, until the end of the year of the latest data", async () => {
|
||||
expect.assertions(3);
|
||||
mockDate("2021-10-10 00:00:00");
|
||||
|
||||
Lead._records = [
|
||||
{ id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
|
||||
{ id: 2, name: "Lead 2", date_deadline: "2022-02-01" },
|
||||
{ id: 3, name: "Lead 3", date_deadline: "2027-11-01" },
|
||||
];
|
||||
onRpc("crm.lead", "web_read_group", ({ kwargs }) => {
|
||||
expect(kwargs.context.fill_temporal).toEqual({
|
||||
fill_from: "2021-01-01",
|
||||
min_groups: 4,
|
||||
});
|
||||
expect(kwargs.domain).toEqual([
|
||||
"&",
|
||||
"|",
|
||||
["date_deadline", "=", false],
|
||||
["date_deadline", ">=", "2021-01-01"],
|
||||
"|",
|
||||
["date_deadline", "=", false],
|
||||
["date_deadline", "<", "2025-01-01"],
|
||||
]);
|
||||
});
|
||||
await mountView({
|
||||
arch: kanbanArch,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
|
||||
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline:year'}"/>
|
||||
</search>`,
|
||||
resModel: "crm.lead",
|
||||
type: "kanban",
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_deadline: true,
|
||||
forecast_field: "date_deadline",
|
||||
},
|
||||
groupBy: ["date_deadline:year"],
|
||||
});
|
||||
expect(
|
||||
getService("fillTemporalService")
|
||||
.getFillTemporalPeriod({
|
||||
modelName: "crm.lead",
|
||||
field: {
|
||||
name: "date_deadline",
|
||||
type: "date",
|
||||
},
|
||||
granularity: "year",
|
||||
})
|
||||
.end.toFormat("yyyy-MM-dd")
|
||||
).toBe("2023-01-01");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("Forecast drag&drop and add column", async () => {
|
||||
mockDate("2023-09-01 00:00:00");
|
||||
Lead._fields.color = fields.Char();
|
||||
Lead._fields.int_field = fields.Integer({ string: "Value" });
|
||||
Lead._records = [
|
||||
{ id: 1, int_field: 7, color: "d", name: "Lead 1", date_deadline: "2023-09-03" },
|
||||
{ id: 2, int_field: 20, color: "w", name: "Lead 2", date_deadline: "2023-09-05" },
|
||||
{ id: 3, int_field: 300, color: "s", name: "Lead 3", date_deadline: "2023-10-10" },
|
||||
];
|
||||
|
||||
onRpc(({ route, method }) => {
|
||||
expect.step(method || route);
|
||||
});
|
||||
await mountView({
|
||||
arch: `
|
||||
<kanban js_class="forecast_kanban">
|
||||
<progressbar field="color" colors='{"s": "success", "w": "warning", "d": "danger"}' sum_field="int_field"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="name"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
|
||||
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
|
||||
</search>`,
|
||||
resModel: "crm.lead",
|
||||
type: "kanban",
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_deadline: true,
|
||||
forecast_field: "date_deadline",
|
||||
},
|
||||
groupBy: ["date_deadline"],
|
||||
});
|
||||
|
||||
const getProgressBarsColors = () =>
|
||||
queryAll(".o_column_progress").map((columnProgressEl) =>
|
||||
queryAll(".progress-bar", { root: columnProgressEl }).map((progressBarEl) =>
|
||||
[...progressBarEl.classList].find((htmlClass) => htmlClass.startsWith("bg-"))
|
||||
)
|
||||
);
|
||||
|
||||
expect(queryAllTexts(".o_animated_number")).toEqual(["27", "300"]);
|
||||
expect(getProgressBarsColors()).toEqual([["bg-warning", "bg-danger"], ["bg-success"]]);
|
||||
|
||||
await contains(".o_kanban_group:first .o_kanban_record").dragAndDrop(".o_kanban_group:eq(1)");
|
||||
await runAllTimers();
|
||||
|
||||
expect(queryAllTexts(".o_animated_number")).toEqual(["20", "307"]);
|
||||
expect(getProgressBarsColors()).toEqual([["bg-warning"], ["bg-success", "bg-danger"]]);
|
||||
|
||||
await quickCreateKanbanColumn();
|
||||
|
||||
// Counters and progressbars should be unchanged after adding a column.
|
||||
expect(queryAllTexts(".o_animated_number")).toEqual(["20", "307"]);
|
||||
expect(getProgressBarsColors()).toEqual([["bg-warning"], ["bg-success", "bg-danger"]]);
|
||||
|
||||
expect.verifySteps([
|
||||
// mountView
|
||||
"get_views",
|
||||
"read_progress_bar",
|
||||
"web_read_group",
|
||||
"has_group",
|
||||
// drag&drop
|
||||
"web_save",
|
||||
"read_progress_bar",
|
||||
"formatted_read_group",
|
||||
// add column
|
||||
"read_progress_bar",
|
||||
"web_read_group",
|
||||
]);
|
||||
});
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { fillTemporalService } from "@crm/views/fill_temporal_service";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import {
|
||||
click,
|
||||
getFixture,
|
||||
patchDate,
|
||||
} from '@web/../tests/helpers/utils';
|
||||
import testUtils from 'web.test_utils';
|
||||
const find = testUtils.dom.find;
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let target;
|
||||
|
||||
QUnit.module('Crm Forecast Model Extension', {
|
||||
beforeEach: async function () {
|
||||
serviceRegistry.add("fillTemporalService", fillTemporalService);
|
||||
this.testKanbanView = {
|
||||
arch: `
|
||||
<kanban js_class="forecast_kanban">
|
||||
<field name="date_deadline"/>
|
||||
<field name="date_closed"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="name"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
|
||||
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
|
||||
<filter name='groupby_date_closed' context="{'group_by':'date_closed'}"/>
|
||||
</search>`,
|
||||
serverData: {
|
||||
models: {
|
||||
'crm.lead': {
|
||||
fields: {
|
||||
name: {string: 'Name', type: 'char'},
|
||||
date_deadline: {string: "Expected closing", type: 'date'},
|
||||
date_closed: {string: "Closed Date", type: 'datetime'},
|
||||
},
|
||||
records: [
|
||||
{id: 1, name: 'Lead 1', date_deadline: '2021-01-01', date_closed: '2021-01-01 00:00:00'},
|
||||
{id: 2, name: 'Lead 2', date_deadline: '2021-01-20', date_closed: '2021-01-20 00:00:00'},
|
||||
{id: 3, name: 'Lead 3', date_deadline: '2021-02-01', date_closed: '2021-02-01 00:00:00'},
|
||||
{id: 4, name: 'Lead 4', date_deadline: '2021-02-20', date_closed: '2021-02-20 00:00:00'},
|
||||
{id: 5, name: 'Lead 5', date_deadline: '2021-03-01', date_closed: '2021-03-01 00:00:00'},
|
||||
{id: 6, name: 'Lead 6', date_deadline: '2021-03-20', date_closed: '2021-03-20 00:00:00'},
|
||||
],
|
||||
},
|
||||
},
|
||||
views: {},
|
||||
},
|
||||
resModel: 'crm.lead',
|
||||
type: "kanban",
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_deadline: true,
|
||||
forecast_field: 'date_deadline',
|
||||
},
|
||||
groupBy: ['date_deadline'],
|
||||
};
|
||||
target = getFixture();
|
||||
setupViewRegistries();
|
||||
patchDate(2021, 1, 10, 0, 0, 0);
|
||||
},
|
||||
|
||||
}, function () {
|
||||
QUnit.test("filter out every records before the start of the current month with forecast_filter for a date field", async function (assert) {
|
||||
// the filter is used by the forecast model extension, and applies the forecast_filter context key,
|
||||
// which adds a domain constraint on the field marked in the other context key forecast_field
|
||||
assert.expect(7);
|
||||
|
||||
await makeView(this.testKanbanView);
|
||||
|
||||
// the filter is active
|
||||
assert.containsN(target, '.o_kanban_group', 2, "There should be 2 columns");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
|
||||
"1st column February should contain 2 record");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
|
||||
"2nd column March should contain 2 records");
|
||||
|
||||
// remove the filter
|
||||
await click(find(target, '.o_searchview_facet', "Forecast"), '.o_facet_remove');
|
||||
|
||||
assert.containsN(target, '.o_kanban_group', 3, "There should be 3 columns");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
|
||||
"1st column January should contain 2 record");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
|
||||
"2nd column February should contain 2 records");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(3) .o_kanban_record', 2,
|
||||
"3nd column March should contain 2 records");
|
||||
});
|
||||
|
||||
QUnit.test("filter out every records before the start of the current month with forecast_filter for a datetime field", async function (assert) {
|
||||
// same tests as for the date field
|
||||
assert.expect(7);
|
||||
|
||||
await makeView({
|
||||
...this.testKanbanView,
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_closed: true,
|
||||
forecast_field: 'date_closed',
|
||||
},
|
||||
groupBy: ['date_closed'],
|
||||
});
|
||||
|
||||
// with the filter
|
||||
assert.containsN(target, '.o_kanban_group', 2, "There should be 2 columns");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
|
||||
"1st column February should contain 2 record");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
|
||||
"2nd column March should contain 2 records");
|
||||
|
||||
// remove the filter
|
||||
await click(find(target, '.o_searchview_facet', "Forecast"), '.o_facet_remove');
|
||||
|
||||
assert.containsN(target, '.o_kanban_group', 3, "There should be 3 columns");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
|
||||
"1st column January should contain 2 record");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
|
||||
"2nd column February should contain 2 records");
|
||||
assert.containsN(target, '.o_kanban_group:nth-child(3) .o_kanban_record', 2,
|
||||
"3nd column March should contain 2 records");
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.module('Crm Fill Temporal Service', {
|
||||
/**
|
||||
* Remark: -> the filter with the groupBy is needed for the model_extension to access the groupby
|
||||
* when created with makeView. Not needed in production.
|
||||
* -> testKanbanView.groupBy is still needed to apply the groupby on the view
|
||||
*/
|
||||
beforeEach: async function () {
|
||||
serviceRegistry.add("fillTemporalService", fillTemporalService);
|
||||
this.testKanbanView = {
|
||||
arch: `
|
||||
<kanban js_class="forecast_kanban">
|
||||
<field name="date_deadline"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="name"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
|
||||
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
|
||||
</search>`
|
||||
,
|
||||
serverData: {
|
||||
models: {
|
||||
'crm.lead': {
|
||||
fields: {
|
||||
name: {string: 'Name', type: 'char'},
|
||||
date_deadline: {string: "Expected Closing", type: 'date'},
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {},
|
||||
},
|
||||
resModel: 'crm.lead',
|
||||
type: "kanban",
|
||||
context: {
|
||||
search_default_forecast: true,
|
||||
search_default_groupby_date_deadline: true,
|
||||
forecast_field: 'date_deadline',
|
||||
},
|
||||
groupBy: ['date_deadline'],
|
||||
};
|
||||
target = getFixture();
|
||||
setupViewRegistries();
|
||||
patchDate(2021, 9, 10, 0, 0, 0);
|
||||
},
|
||||
|
||||
}, function () {
|
||||
/**
|
||||
* Since mock_server does not support fill_temporal,
|
||||
* we only check the domain and the context sent to the read_group, as well
|
||||
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
|
||||
*/
|
||||
QUnit.test("Forecast on months, until the end of the year of the latest data", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.testKanbanView.serverData.models['crm.lead'].records = [
|
||||
{id: 1, name: 'Lead 1', date_deadline: '2021-01-01'},
|
||||
{id: 2, name: 'Lead 2', date_deadline: '2021-02-01'},
|
||||
{id: 3, name: 'Lead 3', date_deadline: '2021-11-01'},
|
||||
{id: 4, name: 'Lead 4', date_deadline: '2022-01-01'},
|
||||
];
|
||||
const kanban = await makeView({
|
||||
...this.testKanbanView,
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/call_kw/crm.lead/web_read_group') {
|
||||
assert.deepEqual(args.kwargs.context.fill_temporal, {
|
||||
fill_from: "2021-10-01",
|
||||
min_groups: 4,
|
||||
});
|
||||
assert.deepEqual(args.kwargs.domain, [
|
||||
"&", "|",
|
||||
["date_deadline", "=", false], ["date_deadline", ">=", "2021-10-01"],
|
||||
"|",
|
||||
["date_deadline", "=", false], ["date_deadline", "<", "2023-01-01"],
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(kanban.env.services.fillTemporalService.getFillTemporalPeriod({
|
||||
modelName: 'crm.lead',
|
||||
field: {
|
||||
name: 'date_deadline',
|
||||
type: 'date',
|
||||
},
|
||||
granularity: 'month',
|
||||
}).end.format('YYYY-MM-DD'), '2022-02-01');
|
||||
});
|
||||
|
||||
/**
|
||||
* Since mock_server does not support fill_temporal,
|
||||
* we only check the domain and the context sent to the read_group, as well
|
||||
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
|
||||
*/
|
||||
QUnit.test("Forecast on years, until the end of the year of the latest data", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.testKanbanView.serverData.models['crm.lead'].records = [
|
||||
{id: 1, name: 'Lead 1', date_deadline: '2021-01-01'},
|
||||
{id: 2, name: 'Lead 2', date_deadline: '2022-02-01'},
|
||||
{id: 3, name: 'Lead 3', date_deadline: '2027-11-01'},
|
||||
];
|
||||
const kanban = await makeView({
|
||||
...this.testKanbanView,
|
||||
groupBy: ['date_deadline:year'],
|
||||
searchViewArch: this.testKanbanView.searchViewArch.replace("'date_deadline'", "'date_deadline:year'"),
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/call_kw/crm.lead/web_read_group') {
|
||||
assert.deepEqual(args.kwargs.context.fill_temporal, {
|
||||
fill_from: "2021-01-01",
|
||||
min_groups: 4,
|
||||
});
|
||||
assert.deepEqual(args.kwargs.domain, [
|
||||
"&", "|",
|
||||
["date_deadline", "=", false], ["date_deadline", ">=", "2021-01-01"],
|
||||
"|",
|
||||
["date_deadline", "=", false], ["date_deadline", "<", "2025-01-01"],
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(kanban.env.services.fillTemporalService.getFillTemporalPeriod({
|
||||
modelName: 'crm.lead',
|
||||
field: {
|
||||
name: 'date_deadline',
|
||||
type: 'date',
|
||||
},
|
||||
granularity: 'year',
|
||||
}).end.format('YYYY-MM-DD'), '2023-01-01');
|
||||
});
|
||||
});
|
||||
119
odoo-bringout-oca-ocb-crm/crm/static/tests/forecast_view.test.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { expect, test } from "@odoo/hoot";
|
||||
import { mockDate } from "@odoo/hoot-mock";
|
||||
import {
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
toggleMenuItem,
|
||||
toggleMenuItemOption,
|
||||
toggleSearchBarMenu,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
class Foo extends models.Model {
|
||||
date_field = fields.Date({ store: true, sortable: true });
|
||||
bar = fields.Many2one({ store: true, relation: "partner", sortable: true });
|
||||
value = fields.Float({ store: true, sortable: true });
|
||||
number = fields.Integer({ store: true, sortable: true });
|
||||
|
||||
_views = {
|
||||
"graph,1": `<graph js_class="forecast_graph"/>`,
|
||||
};
|
||||
}
|
||||
|
||||
class Partner extends models.Model {}
|
||||
|
||||
defineModels([Foo, Partner]);
|
||||
defineMailModels();
|
||||
|
||||
const forecastDomain = (forecastStart) => [
|
||||
"|",
|
||||
["date_field", "=", false],
|
||||
["date_field", ">=", forecastStart],
|
||||
];
|
||||
|
||||
test("Forecast graph view", async () => {
|
||||
expect.assertions(5);
|
||||
mockDate("2021-09-16 16:54:00");
|
||||
|
||||
const expectedDomains = [
|
||||
forecastDomain("2021-09-01"), // month granularity due to no groupby
|
||||
forecastDomain("2021-09-16"), // day granularity due to simple bar groupby
|
||||
// quarter granularity due to date field groupby activated with quarter interval option
|
||||
forecastDomain("2021-07-01"),
|
||||
// quarter granularity due to date field groupby activated with quarter and year interval option
|
||||
forecastDomain("2021-01-01"),
|
||||
// forecast filter no more active
|
||||
[],
|
||||
];
|
||||
|
||||
onRpc("formatted_read_group", ({ kwargs }) => {
|
||||
expect(kwargs.domain).toEqual(expectedDomains.shift());
|
||||
});
|
||||
await mountView({
|
||||
resModel: "foo",
|
||||
viewId: 1,
|
||||
type: "graph",
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }"/>
|
||||
<filter name="group_by_bar" string="Bar" context="{ 'group_by': 'bar' }"/>
|
||||
<filter name="group_by_date_field" string="Date Field" context="{ 'group_by': 'date_field' }"/>
|
||||
</search>
|
||||
`,
|
||||
context: {
|
||||
search_default_forecast_filter: 1,
|
||||
forecast_field: "date_field",
|
||||
},
|
||||
});
|
||||
|
||||
await toggleSearchBarMenu();
|
||||
await toggleMenuItem("Bar");
|
||||
|
||||
await toggleMenuItem("Date Field");
|
||||
await toggleMenuItemOption("Date Field", "Quarter");
|
||||
|
||||
await toggleMenuItemOption("Date Field", "Year");
|
||||
|
||||
await toggleMenuItem("Forecast Filter");
|
||||
});
|
||||
|
||||
test("forecast filter domain is combined with other domains following the same rules as other filters (OR in same group, AND between groups)", async () => {
|
||||
expect.assertions(1);
|
||||
mockDate("2021-09-16 16:54:00");
|
||||
|
||||
onRpc("formatted_read_group", ({ kwargs }) => {
|
||||
expect(kwargs.domain).toEqual([
|
||||
"&",
|
||||
["number", ">", 2],
|
||||
"|",
|
||||
["bar", "=", 2],
|
||||
"&",
|
||||
["value", ">", 0.0],
|
||||
"|",
|
||||
["date_field", "=", false],
|
||||
["date_field", ">=", "2021-09-01"],
|
||||
]);
|
||||
});
|
||||
await mountView({
|
||||
resModel: "foo",
|
||||
type: "graph",
|
||||
viewId: 1,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter name="other_group_filter" string="Other Group Filter" domain="[('number', '>', 2)]"/>
|
||||
<separator/>
|
||||
<filter name="same_group_filter" string="Same Group Filter" domain="[('bar', '=', 2)]"/>
|
||||
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }" domain="[('value', '>', 0.0)]"/>
|
||||
</search>
|
||||
`,
|
||||
context: {
|
||||
search_default_same_group_filter: 1,
|
||||
search_default_forecast_filter: 1,
|
||||
search_default_other_group_filter: 1,
|
||||
forecast_field: "date_field",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { menuService } from "@web/webclient/menus/menu_service";
|
||||
import {
|
||||
toggleFilterMenu,
|
||||
toggleGroupByMenu,
|
||||
toggleMenuItem,
|
||||
toggleMenuItemOption,
|
||||
} from "@web/../tests/search/helpers";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { mock } from "web.test_utils";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { fakeCookieService } from "@web/../tests/helpers/mock_services";
|
||||
|
||||
const patchDate = mock.patchDate;
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
const forecastDomain = (forecastStart) => {
|
||||
return ["|", ["date_field", "=", false], ["date_field", ">=", forecastStart]];
|
||||
};
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
QUnit.module("Views", (hooks) => {
|
||||
hooks.beforeEach(async () => {
|
||||
serverData = {
|
||||
models: {
|
||||
foo: {
|
||||
fields: {
|
||||
date_field: {
|
||||
string: "Date Field",
|
||||
type: "date",
|
||||
store: true,
|
||||
sortable: true,
|
||||
},
|
||||
bar: {
|
||||
string: "Bar",
|
||||
type: "many2one",
|
||||
relation: "partner",
|
||||
store: true,
|
||||
sortable: true,
|
||||
},
|
||||
},
|
||||
records: [],
|
||||
},
|
||||
partner: {},
|
||||
},
|
||||
views: {
|
||||
"foo,false,legacy_toy": `<legacy_toy/>`,
|
||||
"foo,false,graph": `<graph js_class="forecast_graph"/>`,
|
||||
"foo,false,search": `
|
||||
<search>
|
||||
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }"/>
|
||||
<filter name="group_by_bar" string="Bar" context="{ 'group_by': 'bar' }"/>
|
||||
<filter name="group_by_date_field" string="Date Field" context="{ 'group_by': 'date_field' }"/>
|
||||
</search>
|
||||
`,
|
||||
},
|
||||
};
|
||||
setupViewRegistries();
|
||||
serviceRegistry.add("menu", menuService);
|
||||
serviceRegistry.add("cookie", fakeCookieService);
|
||||
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.module("Forecast views");
|
||||
|
||||
QUnit.test("Forecast graph view", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
|
||||
const unpatchDate = patchDate(2021, 8, 16, 16, 54, 0);
|
||||
|
||||
const expectedDomains = [
|
||||
forecastDomain("2021-09-01"), // month granularity due to no groupby
|
||||
forecastDomain("2021-09-16"), // day granularity due to simple bar groupby
|
||||
// quarter granularity due to date field groupby activated with quarter interval option
|
||||
forecastDomain("2021-07-01"),
|
||||
// quarter granularity due to date field groupby activated with quarter and year interval option
|
||||
forecastDomain("2021-01-01"),
|
||||
// forecast filter no more active
|
||||
[],
|
||||
];
|
||||
|
||||
await makeView({
|
||||
resModel: "foo",
|
||||
type: "graph",
|
||||
serverData,
|
||||
searchViewId: false,
|
||||
context: {
|
||||
search_default_forecast_filter: 1,
|
||||
forecast_field: "date_field",
|
||||
},
|
||||
mockRPC(_, args) {
|
||||
if (args.method === "web_read_group") {
|
||||
const { domain } = args.kwargs;
|
||||
assert.deepEqual(domain, expectedDomains.shift());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await toggleGroupByMenu(target);
|
||||
await toggleMenuItem(target, "Bar");
|
||||
|
||||
await toggleMenuItem(target, "Date Field");
|
||||
await toggleMenuItemOption(target, "Date Field", "Quarter");
|
||||
|
||||
await toggleMenuItemOption(target, "Date Field", "Year");
|
||||
|
||||
await toggleFilterMenu(target);
|
||||
await toggleMenuItem(target, "Forecast Filter");
|
||||
|
||||
unpatchDate();
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"forecast filter domain is combined with other domains with an AND",
|
||||
async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const unpatchDate = patchDate(2021, 8, 16, 16, 54, 0);
|
||||
|
||||
serverData.views["foo,false,search"] = `
|
||||
<search>
|
||||
<filter name="other_filter" string="Other Filter" domain="[('bar', '=', 2)]"/>
|
||||
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }"/>
|
||||
</search>
|
||||
`;
|
||||
|
||||
await makeView({
|
||||
resModel: "foo",
|
||||
type: "graph",
|
||||
serverData,
|
||||
searchViewId: false,
|
||||
context: {
|
||||
search_default_other_filter: 1,
|
||||
search_default_forecast_filter: 1,
|
||||
forecast_field: "date_field",
|
||||
},
|
||||
mockRPC(_, args) {
|
||||
if (args.method === "web_read_group") {
|
||||
const { domain } = args.kwargs;
|
||||
assert.deepEqual(domain, [
|
||||
"&",
|
||||
["bar", "=", 2],
|
||||
"|",
|
||||
["date_field", "=", false],
|
||||
["date_field", ">=", "2021-09-01"],
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// note that the facets of the two filters are combined with an OR.
|
||||
// --> current behavior in legacy
|
||||
|
||||
unpatchDate();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, "CRM", {
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
async _performRPC(route, args) {
|
||||
if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') {
|
||||
let message = false;
|
||||
const records = this.models['crm.lead'].records;
|
||||
const record = records.find(r => r.id === args.args[0][0]);
|
||||
const won_stage = this.models['crm.stage'].records.find(s => s.is_won);
|
||||
if (record.stage_id === won_stage.id && record.user_id && record.team_id && record.planned_revenue > 0) {
|
||||
const now = moment();
|
||||
let query_result = {};
|
||||
// Total won
|
||||
query_result['total_won'] = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id).length;
|
||||
// Max team 30 days
|
||||
const recordsTeam30 = records.filter(r => r.stage_id === won_stage.id && r.team_id === record.team_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 30));
|
||||
query_result['max_team_30'] = Math.max(...recordsTeam30.map(r => r.planned_revenue));
|
||||
// Max team 7 days
|
||||
const recordsTeam7 = records.filter(r => r.stage_id === won_stage.id && r.team_id === record.team_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 7));
|
||||
query_result['max_team_7'] = Math.max(...recordsTeam7.map(r => r.planned_revenue));
|
||||
// Max User 30 days
|
||||
const recordsUser30 = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 30));
|
||||
query_result['max_user_30'] = Math.max(...recordsUser30.map(r => r.planned_revenue));
|
||||
// Max User 7 days
|
||||
const recordsUser7 = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 7));
|
||||
query_result['max_user_7'] = Math.max(...recordsUser7.map(r => r.planned_revenue));
|
||||
|
||||
if (query_result.total_won === 1) {
|
||||
message = "Go, go, go! Congrats for your first deal.";
|
||||
} else if (query_result.max_team_30 === record.planned_revenue) {
|
||||
message = "Boom! Team record for the past 30 days.";
|
||||
} else if (query_result.max_team_7 === record.planned_revenue) {
|
||||
message = "Yeah! Deal of the last 7 days for the team.";
|
||||
} else if (query_result.max_user_30 === record.planned_revenue) {
|
||||
message = "You just beat your personal record for the past 30 days.";
|
||||
} else if (query_result.max_user_7 === record.planned_revenue) {
|
||||
message = "You just beat your personal record for the past 7 days.";
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class CrmLead extends models.ServerModel {
|
||||
_name = "crm.lead";
|
||||
_views = {
|
||||
form: /* xml */ `
|
||||
<form string="Lead">
|
||||
<sheet>
|
||||
<field name="name"/>
|
||||
</sheet>
|
||||
</form>`,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,23 +1,25 @@
|
|||
/** @odoo-module */
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
tour.register('create_crm_team_tour', {
|
||||
url: "/web",
|
||||
test: true,
|
||||
}, [
|
||||
...tour.stepUtils.goToAppSteps('crm.crm_menu_root'),
|
||||
registry.category("web_tour.tours").add('create_crm_team_tour', {
|
||||
url: "/odoo",
|
||||
steps: () => [
|
||||
...stepUtils.goToAppSteps('crm.crm_menu_root'),
|
||||
{
|
||||
trigger: 'button[data-menu-xmlid="crm.crm_menu_config"]',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: 'a[data-menu-xmlid="crm.crm_team_config"]',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: 'button.o_list_button_add',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: 'input[id="name"]',
|
||||
run: 'text My CRM Team',
|
||||
trigger: 'input[id="name_0"]',
|
||||
run: "edit My CRM Team",
|
||||
}, {
|
||||
trigger: 'button.o-kanban-button-new',
|
||||
trigger: '.btn.o-kanban-button-new',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: 'div.modal-dialog tr:contains("Test Salesman") input.form-check-input',
|
||||
run: 'click',
|
||||
|
|
@ -26,9 +28,11 @@ tour.register('create_crm_team_tour', {
|
|||
run: 'click',
|
||||
}, {
|
||||
trigger: 'div.modal-dialog tr:contains("Test Sales Manager") input.form-check-input:checked',
|
||||
run: () => {},
|
||||
}, {
|
||||
trigger: '.o_selection_box:contains(2)',
|
||||
}, {
|
||||
trigger: 'button.o_select_button',
|
||||
},
|
||||
...tour.stepUtils.saveForm()
|
||||
]);
|
||||
run: "click",
|
||||
},
|
||||
...stepUtils.saveForm()
|
||||
]});
|
||||
|
|
|
|||
|
|
@ -1,56 +1,25 @@
|
|||
odoo.define('crm.crm_email_and_phone_propagation', function (require) {
|
||||
'use strict';
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
const tour = require('web_tour.tour');
|
||||
|
||||
tour.register('crm_email_and_phone_propagation_edit_save', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
}, [
|
||||
tour.stepUtils.showAppsMenuItem(),
|
||||
registry.category("web_tour.tours").add('crm_email_and_phone_propagation_edit_save', {
|
||||
url: '/odoo',
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
|
||||
content: 'open crm app',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '.o_kanban_record .o_kanban_record_title span:contains(Test Lead Propagation)',
|
||||
trigger: '.o_kanban_record:contains(Test Lead Propagation)',
|
||||
content: 'Open the first lead',
|
||||
run: 'click',
|
||||
}, {
|
||||
trigger: '.o_form_button_save',
|
||||
extra_trigger: '.o_form_editable .o_field_widget[name=email_from] input',
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_editable .o_field_widget[name=email_from] input",
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_button_save:not(:visible)",
|
||||
content: 'Save the lead',
|
||||
run: 'click',
|
||||
},
|
||||
]);
|
||||
|
||||
tour.register('crm_email_and_phone_propagation_remove_email_and_phone', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
}, [
|
||||
tour.stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
|
||||
content: 'open crm app',
|
||||
}, {
|
||||
trigger: '.o_kanban_record .o_kanban_record_title span:contains(Test Lead Propagation)',
|
||||
content: 'Open the first lead',
|
||||
run: 'click',
|
||||
}, {
|
||||
trigger: '.o_form_editable .o_field_widget[name=email_from] input',
|
||||
extra_trigger: '.o_form_editable .o_field_widget[name=phone] input',
|
||||
content: 'Remove the email and the phone',
|
||||
run: function (action) {
|
||||
action.remove_text("", ".o_form_editable .o_field_widget[name=email_from] input");
|
||||
action.remove_text("", ".o_form_editable .o_field_widget[name=phone] input");
|
||||
},
|
||||
}, {
|
||||
trigger: '.o_back_button',
|
||||
// wait the warning message to be visible
|
||||
extra_trigger: '.o_form_sheet_bg .fa-exclamation-triangle:not(.o_invisible_modifier)',
|
||||
content: 'Save the lead and exit to kanban',
|
||||
run: 'click',
|
||||
},{
|
||||
trigger: '.o_kanban_renderer',
|
||||
}
|
||||
]);
|
||||
});
|
||||
]});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
/** @odoo-module */
|
||||
import tour from 'web_tour.tour';
|
||||
const today = moment();
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
const today = luxon.DateTime.now();
|
||||
|
||||
tour.register('crm_forecast', {
|
||||
test: true,
|
||||
url: "/web",
|
||||
}, [
|
||||
tour.stepUtils.showAppsMenuItem(),
|
||||
registry.category("web_tour.tours").add('crm_forecast', {
|
||||
url: "/odoo",
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
|
||||
content: "open crm app",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '.dropdown-toggle[data-menu-xmlid="crm.crm_menu_report"]',
|
||||
content: 'Open Reporting menu',
|
||||
|
|
@ -19,8 +19,8 @@ tour.register('crm_forecast', {
|
|||
content: 'Open Forecast menu',
|
||||
run: 'click',
|
||||
}, {
|
||||
trigger: '.o_column_quick_create:contains(Add next month)',
|
||||
content: 'Wait page loading'
|
||||
trigger: '.o_column_quick_create',
|
||||
content: 'Wait page loading',
|
||||
}, {
|
||||
trigger: ".o-kanban-button-new",
|
||||
content: "click create",
|
||||
|
|
@ -28,69 +28,66 @@ tour.register('crm_forecast', {
|
|||
}, {
|
||||
trigger: ".o_field_widget[name=name] input",
|
||||
content: "complete name",
|
||||
run: "text Test Opportunity 1",
|
||||
run: "edit Test Opportunity 1",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=expected_revenue] input",
|
||||
content: "complete expected revenue",
|
||||
run: "text 999999",
|
||||
run: "edit 999999",
|
||||
}, {
|
||||
trigger: "button.o_kanban_edit",
|
||||
content: "edit lead",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: "div[name=date_deadline] button",
|
||||
content: "open date picker",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: "div[name=date_deadline] input",
|
||||
content: "complete expected closing",
|
||||
run: `text ${today.format("MM/DD/YYYY")}`,
|
||||
run: `edit ${today.toFormat("MM/dd/yyyy")}`,
|
||||
}, {
|
||||
trigger: "div[name=date_deadline] input",
|
||||
content: "click to make the datepicker disappear",
|
||||
run: "click"
|
||||
}, {
|
||||
trigger: "body:not(:has(div.bootstrap-datetimepicker-widget))",
|
||||
content: "wait for date_picker to disappear",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: '.o_back_button',
|
||||
content: 'navigate back to the kanban view',
|
||||
position: "bottom",
|
||||
tooltipPosition: "bottom",
|
||||
run: "click"
|
||||
}, {
|
||||
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Opportunity 1')",
|
||||
trigger: ".o_kanban_record:contains('Test Opportunity 1')",
|
||||
content: "move to the next month",
|
||||
run: function (actions) {
|
||||
const undefined_groups = $('.o_column_title:contains("None")').length;
|
||||
actions.drag_and_drop_native(`.o_opportunity_kanban .o_kanban_group:eq(${1 + undefined_groups})`, this.$anchor);
|
||||
async run({ queryAll, drag_and_drop }) {
|
||||
const undefined_groups = queryAll('.o_column_title:contains("None")').length;
|
||||
await drag_and_drop(`.o_opportunity_kanban .o_kanban_group:eq(${1 + undefined_groups})`);
|
||||
},
|
||||
}, {
|
||||
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Opportunity 1')",
|
||||
trigger: ".o_kanban_record:contains('Test Opportunity 1')",
|
||||
content: "edit lead",
|
||||
run: "click"
|
||||
}, {
|
||||
trigger: "div[name=date_deadline] button",
|
||||
content: "open date picker",
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=date_deadline] input",
|
||||
content: "complete expected closing",
|
||||
run: function (actions) {
|
||||
actions.text(`text ${moment(today).add(5, 'months').startOf('month').subtract(1, 'days').format("MM/DD/YYYY")}`, this.$anchor);
|
||||
this.$anchor[0].dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
},
|
||||
run: `edit ${today.plus({ months: 5 }).startOf("month").minus({ days: 1 }).toFormat("MM/dd/yyyy")} && press Escape`,
|
||||
}, {
|
||||
trigger: "body:not(:has(div.bootstrap-datetimepicker-widget))",
|
||||
content: "wait for date_picker to disappear",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=probability] input",
|
||||
content: "max out probability",
|
||||
run: "text 100"
|
||||
trigger: "button[name=action_set_won_rainbowman]",
|
||||
content: "win the lead",
|
||||
run:"click"
|
||||
}, {
|
||||
trigger: '.o_back_button',
|
||||
content: 'navigate back to the kanban view',
|
||||
position: "bottom",
|
||||
tooltipPosition: "bottom",
|
||||
run: "click"
|
||||
}, {
|
||||
trigger: '.o_kanban_add_column',
|
||||
trigger: '.o_column_quick_create.o_quick_create_folded div',
|
||||
content: "add next month",
|
||||
run: "click"
|
||||
}, {
|
||||
trigger: ".o_kanban_record:contains('Test Opportunity 1'):contains('Won')",
|
||||
content: "assert that the opportunity has the Won banner",
|
||||
run: function () {},
|
||||
}
|
||||
]);
|
||||
]});
|
||||
|
|
|
|||
|
|
@ -1,87 +1,119 @@
|
|||
/** @odoo-module **/
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
tour.register('crm_rainbowman', {
|
||||
test: true,
|
||||
url: "/web",
|
||||
}, [
|
||||
tour.stepUtils.showAppsMenuItem(),
|
||||
registry.category("web_tour.tours").add("crm_rainbowman", {
|
||||
url: "/odoo",
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
|
||||
content: "open crm app",
|
||||
}, {
|
||||
trigger: ".o-kanban-button-new",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: "body:has(.o_kanban_renderer) .o-kanban-button-new",
|
||||
content: "click create",
|
||||
}, {
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name=name] input",
|
||||
content: "complete name",
|
||||
run: "text Test Lead 1",
|
||||
}, {
|
||||
run: "edit Test Lead 1",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name=expected_revenue] input",
|
||||
content: "complete expected revenue",
|
||||
run: "text 999999997",
|
||||
}, {
|
||||
run: "edit 999999997",
|
||||
},
|
||||
{
|
||||
trigger: "button.o_kanban_add",
|
||||
content: "create lead",
|
||||
}, {
|
||||
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 1')",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_kanban_record:contains('Test Lead 1')",
|
||||
content: "move to won stage",
|
||||
run: "drag_and_drop_native .o_opportunity_kanban .o_kanban_group:has(.o_column_title:contains('Won')) "
|
||||
}, {
|
||||
run: "drag_and_drop (.o_opportunity_kanban .o_kanban_group:has(.o_column_title:contains('Won')))",
|
||||
},
|
||||
{
|
||||
trigger: ".o_reward_rainbow",
|
||||
extra_trigger: ".o_reward_rainbow",
|
||||
run: function () {} // check rainbowman is properly displayed
|
||||
}, {
|
||||
},
|
||||
{
|
||||
// This step and the following simulates the fact that after drag and drop,
|
||||
// from the previous steps, a click event is triggered on the window element,
|
||||
// which closes the currently shown .o_kanban_quick_create.
|
||||
trigger: ".o_kanban_renderer",
|
||||
}, {
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_kanban_renderer:not(:has(.o_kanban_quick_create))",
|
||||
run() {},
|
||||
}, {
|
||||
},
|
||||
{
|
||||
trigger: ".o-kanban-button-new",
|
||||
content: "create second lead",
|
||||
}, {
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name=name] input",
|
||||
content: "complete name",
|
||||
run: "text Test Lead 2",
|
||||
}, {
|
||||
run: "edit Test Lead 2",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name=expected_revenue] input",
|
||||
content: "complete expected revenue",
|
||||
run: "text 999999998",
|
||||
}, {
|
||||
run: "edit 999999998",
|
||||
},
|
||||
{
|
||||
trigger: "button.o_kanban_add",
|
||||
content: "create lead",
|
||||
}, {
|
||||
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 2')",
|
||||
run: function () {} // wait for the record to be properly created
|
||||
}, {
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_kanban_record:contains('Test Lead 2')",
|
||||
},
|
||||
{
|
||||
// move first test back to new stage to be able to test rainbowman a second time
|
||||
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 1')",
|
||||
trigger: ".o_kanban_record:contains('Test Lead 1')",
|
||||
content: "move back to new stage",
|
||||
run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(0) "
|
||||
}, {
|
||||
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 2')",
|
||||
run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(0) ",
|
||||
},
|
||||
{
|
||||
trigger: ".o_kanban_record:contains('Test Lead 2')",
|
||||
content: "click on second lead",
|
||||
}, {
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_statusbar_status button[data-value='4']",
|
||||
content: "move lead to won stage",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "wait for save completion",
|
||||
trigger: ".o_form_readonly, .o_form_saved",
|
||||
},
|
||||
{
|
||||
trigger: ".o_reward_rainbow",
|
||||
},
|
||||
...tour.stepUtils.saveForm(),
|
||||
{
|
||||
trigger: ".o_statusbar_status button[data-value='1']",
|
||||
extra_trigger: ".o_reward_rainbow",
|
||||
content: "move lead to previous stage & rainbowman appears",
|
||||
}, {
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: "button[name=action_set_won_rainbowman]",
|
||||
content: "click button mark won",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "wait for save completion",
|
||||
trigger: ".o_form_readonly, .o_form_saved",
|
||||
},
|
||||
{
|
||||
trigger: ".o_reward_rainbow",
|
||||
},
|
||||
...tour.stepUtils.saveForm(),
|
||||
{
|
||||
trigger: ".o_menu_brand",
|
||||
extra_trigger: ".o_reward_rainbow",
|
||||
content: "last rainbowman appears",
|
||||
}
|
||||
]);
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||