Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
frontend/crm/static/src/activity_menu_patch.js
Normal file
55
frontend/crm/static/src/activity_menu_patch.js
Normal file
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
19
frontend/crm/static/src/core/common/crm_lead_model.js
Normal file
19
frontend/crm/static/src/core/common/crm_lead_model.js
Normal file
@@ -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");
|
||||
},
|
||||
});
|
||||
BIN
frontend/crm/static/src/img/milk-autofill.gif
Normal file
BIN
frontend/crm/static/src/img/milk-autofill.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/crm/static/src/img/milk-generate-leads.gif
Normal file
BIN
frontend/crm/static/src/img/milk-generate-leads.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/crm/static/src/img/milk-mapview-toggle.gif
Normal file
BIN
frontend/crm/static/src/img/milk-mapview-toggle.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
frontend/crm/static/src/img/milk-pipeline-progress.gif
Normal file
BIN
frontend/crm/static/src/img/milk-pipeline-progress.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/crm/static/src/img/milk-probability-rate.gif
Normal file
BIN
frontend/crm/static/src/img/milk-probability-rate.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/crm/static/src/img/pls-tooltip-ai-icon.png
Normal file
BIN
frontend/crm/static/src/img/pls-tooltip-ai-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 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,
|
||||
}),
|
||||
});
|
||||
101
frontend/crm/static/src/js/tours/crm.js
Normal file
101
frontend/crm/static/src/js/tours/crm.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
import { markup } from "@odoo/owl";
|
||||
|
||||
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>.')),
|
||||
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>.')),
|
||||
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_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",
|
||||
}, {
|
||||
isActive: ["auto"],
|
||||
trigger: ".ui-menu-item > a:contains('Brandon Freeman')",
|
||||
run: "click",
|
||||
}, {
|
||||
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.")),
|
||||
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: '.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!")),
|
||||
tooltipPosition: "right",
|
||||
run: "drag_and_drop(.o_opportunity_kanban .o_kanban_group:eq(3))",
|
||||
},
|
||||
{
|
||||
trigger: ".o_kanban_record",
|
||||
content: _t("Let’s have a look at an Opportunity."),
|
||||
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."),
|
||||
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."),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click .breadcrumb-item:not(.active):last",
|
||||
}]});
|
||||
16
frontend/crm/static/src/scss/crm.scss
Normal file
16
frontend/crm/static/src/scss/crm.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.crm_lead_merge_summary blockquote {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.crm_quick_create_opportunity_form_group {
|
||||
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
i.fa {
|
||||
text-align: center;
|
||||
min-width: 2rem;
|
||||
}
|
||||
.crm_revenues .o_row {
|
||||
margin-left: 1.75rem;
|
||||
}
|
||||
}
|
||||
11
frontend/crm/static/src/scss/crm_team.scss
Normal file
11
frontend/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;
|
||||
}
|
||||
}
|
||||
6
frontend/crm/static/src/scss/crm_team_member_views.scss
Normal file
6
frontend/crm/static/src/scss/crm_team_member_views.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.o_crm_team_member_kanban .o_kanban_renderer {
|
||||
.o_member_assignment div.oe_gauge {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export async function checkRainbowmanMessage(orm, effect, recordId) {
|
||||
const message = await orm.call("crm.lead", "get_rainbowman_message", [[recordId]]);
|
||||
if (message) {
|
||||
effect.add({
|
||||
message,
|
||||
type: "rainbow_man",
|
||||
});
|
||||
}
|
||||
}
|
||||
70
frontend/crm/static/src/views/crm_form/crm_form.js
Normal file
70
frontend/crm/static/src/views/crm_form/crm_form.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
|
||||
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.
|
||||
*
|
||||
* This method will also simulate a real "force_save" on the email and phone
|
||||
* when needed. The "force_save" attribute only works on readonly field. For our
|
||||
* use case, we need to write the email and the phone even if the user didn't
|
||||
* change them, to synchronize those values with the partner (so the email / phone
|
||||
* inverse method can be called).
|
||||
*
|
||||
* We base this synchronization on the value of "partner_phone_update"
|
||||
* and "partner_email_update", which are computed fields that hold a value
|
||||
* whenever we need to synch.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async _save() {
|
||||
if (this.resModel !== "crm.lead") {
|
||||
return super._save(...arguments);
|
||||
}
|
||||
let changeStage = false;
|
||||
const needsSynchronizationEmail =
|
||||
this._changes.partner_email_update === undefined
|
||||
? this._values.partner_email_update // original value
|
||||
: this._changes.partner_email_update; // new value
|
||||
|
||||
const needsSynchronizationPhone =
|
||||
this._changes.partner_phone_update === undefined
|
||||
? this._values.partner_phone_update // original value
|
||||
: this._changes.partner_phone_update; // new value
|
||||
|
||||
if (needsSynchronizationEmail && this._changes.email_from === undefined && this._values.email_from) {
|
||||
this._changes.email_from = this._values.email_from;
|
||||
}
|
||||
if (needsSynchronizationPhone && this._changes.phone === undefined && this._values.phone) {
|
||||
this._changes.phone = this._values.phone;
|
||||
}
|
||||
|
||||
if ("stage_id" in this._changes) {
|
||||
changeStage = this._values.stage_id !== this.data.stage_id;
|
||||
}
|
||||
|
||||
const res = await super._save(...arguments);
|
||||
if (changeStage) {
|
||||
await checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
class CrmFormModel extends formView.Model {
|
||||
static Record = CrmFormRecord;
|
||||
static services = [...formView.Model.services, "effect"];
|
||||
|
||||
setup(params, services) {
|
||||
super.setup(...arguments);
|
||||
this.effect = services.effect;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("views").add("crm_form", {
|
||||
...formView,
|
||||
Model: CrmFormModel,
|
||||
});
|
||||
5
frontend/crm/static/src/views/crm_form/crm_form.scss
Normal file
5
frontend/crm/static/src/views/crm_form/crm_form.scss
Normal file
@@ -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>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { KanbanArchParser } from "@web/views/kanban/kanban_arch_parser";
|
||||
import { extractAttributes } from "@web/core/utils/xml";
|
||||
|
||||
export class CrmKanbanArchParser extends KanbanArchParser {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
parseProgressBar(progressBar, fields) {
|
||||
const result = super.parseProgressBar(...arguments);
|
||||
const attrs = extractAttributes(progressBar, ["recurring_revenue_sum_field"]);
|
||||
result.recurring_revenue_sum_field = fields[attrs.recurring_revenue_sum_field] || false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
35
frontend/crm/static/src/views/crm_kanban/crm_kanban_model.js
Normal file
35
frontend/crm/static/src/views/crm_kanban/crm_kanban_model.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
|
||||
import { RelationalModel } from "@web/model/relational_model/relational_model";
|
||||
|
||||
export class CrmKanbanModel extends RelationalModel {
|
||||
setup(params, { effect }) {
|
||||
super.setup(...arguments);
|
||||
this.effect = effect;
|
||||
}
|
||||
}
|
||||
|
||||
export class CrmKanbanDynamicGroupList extends RelationalModel.DynamicGroupList {
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* If the kanban view is grouped by stage_id check if the lead is won and display
|
||||
* a rainbowman message if that's the case.
|
||||
*/
|
||||
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
|
||||
await super.moveRecord(...arguments);
|
||||
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
|
||||
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
|
||||
if (
|
||||
dataGroupId !== targetGroupId &&
|
||||
sourceGroup &&
|
||||
targetGroup &&
|
||||
sourceGroup.groupByField.name === "stage_id"
|
||||
) {
|
||||
const record = targetGroup.list.records.find((r) => r.id === dataRecordId);
|
||||
await checkRainbowmanMessage(this.model.orm, this.model.effect, record.resId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CrmKanbanModel.DynamicGroupList = CrmKanbanDynamicGroupList;
|
||||
CrmKanbanModel.services = [...RelationalModel.services, "effect"];
|
||||
@@ -0,0 +1,19 @@
|
||||
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";
|
||||
|
||||
class CrmKanbanHeader extends RottingKanbanHeader {
|
||||
static components = {
|
||||
...RottingKanbanHeader.components,
|
||||
ColumnProgress: CrmColumnProgress,
|
||||
};
|
||||
}
|
||||
|
||||
export class CrmKanbanRenderer extends RottingKanbanRenderer {
|
||||
static components = {
|
||||
...RottingKanbanRenderer.components,
|
||||
KanbanHeader: CrmKanbanHeader,
|
||||
KanbanRecord: RottingKanbanRecord,
|
||||
};
|
||||
}
|
||||
25
frontend/crm/static/src/views/crm_kanban/crm_kanban_view.js
Normal file
25
frontend/crm/static/src/views/crm_kanban/crm_kanban_view.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
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 = {
|
||||
...rottingKanbanView,
|
||||
ArchParser: CrmKanbanArchParser,
|
||||
// Makes it easier to patch
|
||||
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,
|
||||
};
|
||||
|
||||
registry.category("views").add("crm_kanban", crmKanbanView);
|
||||
304
frontend/crm/static/src/views/fill_temporal_service.js
Normal file
304
frontend/crm/static/src/views/fill_temporal_service.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
serializeDate,
|
||||
serializeDateTime,
|
||||
} from "@web/core/l10n/dates";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* ___________________________
|
||||
* 1 day hour
|
||||
* 1 week day
|
||||
* 1 week week # there is no greater time period that takes an integer amount of weeks
|
||||
* 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 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,
|
||||
},
|
||||
day: {
|
||||
startOf: (x) => x.startOf("day"),
|
||||
cycle: 7,
|
||||
cyclePos: (x) => x.weekday,
|
||||
},
|
||||
week: {
|
||||
startOf: (x) => x.startOf("week"),
|
||||
cycle: 1,
|
||||
cyclePos: (x) => 1,
|
||||
},
|
||||
month: {
|
||||
startOf: (x) => x.startOf("month"),
|
||||
cycle: 12,
|
||||
cyclePos: (x) => x.month,
|
||||
},
|
||||
quarter: {
|
||||
startOf: (x) => x.startOf("quarter"),
|
||||
cycle: 4,
|
||||
cyclePos: (x) => x.quarter,
|
||||
},
|
||||
year: {
|
||||
startOf: (x) => x.startOf("year"),
|
||||
cycle: 1,
|
||||
cyclePos: (x) => 1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* fill_temporal period:
|
||||
* Represents a specific date/time range for a specific model, field and granularity.
|
||||
*
|
||||
* It is used to add new domain and context constraints related to a specific date/time
|
||||
* field, in order to configure the _read_group_fill_temporal (see core models.py)
|
||||
* method. It will be used when we want to get continuous groups in chronological
|
||||
* order in a specific date/time range.
|
||||
*/
|
||||
export class FillTemporalPeriod {
|
||||
/**
|
||||
* This constructor is meant to be used only by the FillTemporalService (see below)
|
||||
*
|
||||
* @param {string} modelName directly taken from model.loadParams.modelName.
|
||||
* this is the `res_model` from the action (i.e. `crm.lead`)
|
||||
* @param {Object} field a dictionary with keys "name" and "type".
|
||||
* name: Name of the field on which the fill_temporal should apply
|
||||
* (i.e. 'date_deadline')
|
||||
* type: 'date' or 'datetime'
|
||||
* @param {string} granularity can either be : hour, day, week, month, quarter, year
|
||||
* @param {integer} minGroups minimum amount of groups to display, regardless of other
|
||||
* constraints
|
||||
*/
|
||||
constructor(modelName, field, granularity, minGroups) {
|
||||
this.modelName = modelName;
|
||||
this.field = field;
|
||||
this.granularity = granularity || "month";
|
||||
this.setMinGroups(minGroups);
|
||||
|
||||
this._computeStart();
|
||||
this._computeEnd();
|
||||
}
|
||||
/**
|
||||
* 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(luxon.DateTime.now());
|
||||
}
|
||||
/**
|
||||
* 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 :
|
||||
* [start] = 2020-10-01
|
||||
* [granularity] = 'month',
|
||||
* [cycle] = 12
|
||||
* [minGroups] = 4,
|
||||
* => fillTemporalPeriod = 15 months (until end of december 2021)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_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!
|
||||
const fillTemporalPeriod = ((2 * cycle - ((this.minGroups - 1) % cycle) - cyclePos) % cycle) + this.minGroups;
|
||||
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 {DateTime} bound the DateTime to be formatted (this.start or this.end)
|
||||
*/
|
||||
_getFormattedServerDate(bound) {
|
||||
if (this.field.type === "date") {
|
||||
return serializeDate(bound);
|
||||
} else {
|
||||
return serializeDateTime(bound);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Object} configuration
|
||||
* @param {Array[]} [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 DateTime must be used as a domain
|
||||
* constraint to limit read_group results or not
|
||||
* @returns {Array[]} new domain
|
||||
*/
|
||||
getDomain({ domain, forceStartBound = true, forceEndBound = true }) {
|
||||
if (!forceEndBound && !forceStartBound) {
|
||||
return domain;
|
||||
}
|
||||
const originalDomain = domain.length ? ["&", ...domain] : [];
|
||||
const defaultDomain = ["|", [this.field.name, "=", false]];
|
||||
const linkDomain = forceStartBound && forceEndBound ? ["&"] : [];
|
||||
const startDomain = !forceStartBound ? [] : [[this.field.name, ">=", this._getFormattedServerDate(this.start)]];
|
||||
const endDomain = !forceEndBound ? [] : [[this.field.name, "<", this._getFormattedServerDate(this.end)]];
|
||||
return [...originalDomain, ...defaultDomain, ...linkDomain, ...startDomain, ...endDomain];
|
||||
}
|
||||
/**
|
||||
* The default value of forceFillingTo is false when this.end is the
|
||||
* computed one, and true when it is manually set. This is because the default value of
|
||||
* this.end is computed without any knowledge of the existing data, and as such, we only
|
||||
* want to get continuous groups until the last group with data (no need to force until
|
||||
* this.end). On the contrary, when we set this.end, this means that we want groups until
|
||||
* that date.
|
||||
*
|
||||
* @param {Object} configuration
|
||||
* @param {Object} [context]
|
||||
* @param {boolean} [forceFillingFrom=true] fill_temporal must apply from:
|
||||
* true: this.start
|
||||
* false: the first group with at least one record
|
||||
* @param {boolean} [forceFillingTo=!this.computedEnd] fill_temporal must apply until:
|
||||
* true: this.end
|
||||
* false: the last group with at least one record
|
||||
* @returns {Object} new context
|
||||
*/
|
||||
getContext({ context, forceFillingFrom = true, forceFillingTo = !this.computedEnd }) {
|
||||
const fillTemporal = {
|
||||
min_groups: this.minGroups,
|
||||
};
|
||||
if (forceFillingFrom) {
|
||||
fillTemporal.fill_from = this._getFormattedServerDate(this.start);
|
||||
}
|
||||
if (forceFillingTo) {
|
||||
// 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;
|
||||
}
|
||||
/**
|
||||
* @param {integer} minGroups minimum amount of groups to display, regardless of other
|
||||
* constraints
|
||||
*/
|
||||
setMinGroups(minGroups) {
|
||||
this.minGroups = minGroups || 1;
|
||||
}
|
||||
/**
|
||||
* 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 {DateTime} end
|
||||
*/
|
||||
setEnd(end) {
|
||||
this.end = luxon.DateTime.max(this.start, end);
|
||||
this.computedEnd = false;
|
||||
}
|
||||
/**
|
||||
* sets the start of the period to the desired DateTime. It must be smaller than end
|
||||
*
|
||||
* @param {DateTime} start
|
||||
*/
|
||||
setStart(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.plus({[`${this.granularity}s`]: 1}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fill_temporal Service
|
||||
*
|
||||
* This service will be used to generate or retrieve fill_temporal periods
|
||||
*
|
||||
* A specific fill_temporal period configuration will always refer to the same instance
|
||||
* unless forceRecompute is true
|
||||
*/
|
||||
export const fillTemporalService = {
|
||||
start() {
|
||||
const _fillTemporalPeriods = {};
|
||||
|
||||
/**
|
||||
* Get a fill_temporal period according to the configuration.
|
||||
* The default initial 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 :
|
||||
* [start] = 2020-10-01
|
||||
* [granularity] = 'month',
|
||||
* [cycle] = 12 (one year)
|
||||
* [minGroups] = 4,
|
||||
* => fillTemporalPeriod = 15 months (until the end of december 2021)
|
||||
* Once created, a fill_temporal period for a specific configuration will be stored
|
||||
* until requested again. This allows to manipulate the period and store the changes
|
||||
* to it. This also allows to keep the configuration when switching to another view
|
||||
*
|
||||
* @param {Object} configuration
|
||||
* @param {string} [modelName] directly taken from model.loadParams.modelName.
|
||||
* this is the `res_model` from the action (i.e. `crm.lead`)
|
||||
* @param {Object} [field] a dictionary with keys "name" and "type".
|
||||
* @param {string} [field.name] name of the field on which the fill_temporal should apply
|
||||
* (i.e. 'date_deadline')
|
||||
* @param {string} [field.type] date field type: 'date' or 'datetime'
|
||||
* @param {string} [granularity] can either be : hour, day, week, month, quarter, year
|
||||
* @param {integer} [minGroups=4] optional minimal amount of desired groups
|
||||
* @param {boolean} [forceRecompute=false] optional whether the fill_temporal period should be
|
||||
* reinstancied
|
||||
* @returns {FillTemporalPeriod}
|
||||
*/
|
||||
const getFillTemporalPeriod = ({ modelName, field, granularity, minGroups = 4, forceRecompute = false }) => {
|
||||
if (!(modelName in _fillTemporalPeriods)) {
|
||||
_fillTemporalPeriods[modelName] = {};
|
||||
}
|
||||
if (!(field.name in _fillTemporalPeriods[modelName])) {
|
||||
_fillTemporalPeriods[modelName][field.name] = {};
|
||||
}
|
||||
if (!(granularity in _fillTemporalPeriods[modelName][field.name]) || forceRecompute) {
|
||||
_fillTemporalPeriods[modelName][field.name][granularity] = new FillTemporalPeriod(
|
||||
modelName,
|
||||
field,
|
||||
granularity,
|
||||
minGroups
|
||||
);
|
||||
} else if (_fillTemporalPeriods[modelName][field.name][granularity].minGroups != minGroups) {
|
||||
_fillTemporalPeriods[modelName][field.name][granularity].setMinGroups(minGroups);
|
||||
}
|
||||
return _fillTemporalPeriods[modelName][field.name][granularity];
|
||||
};
|
||||
return { getFillTemporalPeriod };
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("fillTemporalService", fillTemporalService);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
|
||||
|
||||
export const forecastGraphView = {
|
||||
...graphView,
|
||||
SearchModel: ForecastSearchModel,
|
||||
};
|
||||
|
||||
registry.category("views").add("forecast_graph", forecastGraphView);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { INTERVAL_OPTIONS } from "@web/search/utils/dates";
|
||||
import { KanbanColumnQuickCreate } from "@web/views/kanban/kanban_column_quick_create";
|
||||
|
||||
export class ForecastKanbanColumnQuickCreate extends KanbanColumnQuickCreate {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get relatedFieldName() {
|
||||
const { granularity = "month" } = this.props.groupByField;
|
||||
const { description } = INTERVAL_OPTIONS[granularity];
|
||||
return _t("next %s", description.toLocaleLowerCase());
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Create column directly upon "unfolding" quick create.
|
||||
*/
|
||||
unfold() {
|
||||
this.props.onValidate();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
|
||||
|
||||
export class ForecastKanbanModel extends CrmKanbanModel {
|
||||
setup(params, { fillTemporalService }) {
|
||||
super.setup(...arguments);
|
||||
this.fillTemporalService = fillTemporalService;
|
||||
this.forceNextRecompute = !params.state?.groups;
|
||||
this.originalDomain = null;
|
||||
this.fillTemporalDomain = null;
|
||||
}
|
||||
|
||||
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 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
|
||||
*/
|
||||
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.fillTemporalService.getFillTemporalPeriod({
|
||||
modelName: config.resModel,
|
||||
field: {
|
||||
name,
|
||||
type,
|
||||
},
|
||||
granularity: granularity || "month",
|
||||
minGroups,
|
||||
forceRecompute,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ForecastKanbanModel.services = [...CrmKanbanModel.services, "fillTemporalService"];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { ForecastKanbanColumnQuickCreate } from "@crm/views/forecast_kanban/forecast_kanban_column_quick_create";
|
||||
|
||||
export class ForecastKanbanRenderer extends CrmKanbanRenderer {
|
||||
static template = "crm.ForecastKanbanRenderer";
|
||||
static components = {
|
||||
...CrmKanbanRenderer.components,
|
||||
ForecastKanbanColumnQuickCreate,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.fillTemporalService = useService("fillTemporalService");
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Allow creating groups when grouping by forecast_field.
|
||||
*/
|
||||
canCreateGroup() {
|
||||
return super.canCreateGroup(...arguments) || this.isGroupedByForecastField();
|
||||
}
|
||||
|
||||
isGroupedByForecastField() {
|
||||
return (
|
||||
this.props.list.context.forecast_field &&
|
||||
this.props.list.groupByField.name === this.props.list.context.forecast_field
|
||||
);
|
||||
}
|
||||
|
||||
isMovableField(field) {
|
||||
return super.isMovableField(...arguments) || field.name === "date_deadline";
|
||||
}
|
||||
|
||||
async addForecastColumn() {
|
||||
const { name, type, granularity } = this.props.list.groupByField;
|
||||
this.fillTemporalService
|
||||
.getFillTemporalPeriod({
|
||||
modelName: this.props.list.resModel,
|
||||
field: {
|
||||
name,
|
||||
type,
|
||||
},
|
||||
granularity: granularity || "month",
|
||||
})
|
||||
.expand();
|
||||
await this.props.list.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<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"
|
||||
groupByField="props.list.groupByField"
|
||||
/>
|
||||
</t>
|
||||
<t t-else="">$0</t>
|
||||
</KanbanColumnQuickCreate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,18 @@
|
||||
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 { 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,
|
||||
};
|
||||
|
||||
registry.category("views").add("forecast_kanban", forecastKanbanView);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
|
||||
|
||||
export const forecastListView = {
|
||||
...listView,
|
||||
SearchModel: ForecastSearchModel,
|
||||
};
|
||||
|
||||
registry.category("views").add("forecast_list", forecastListView);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { pivotView } from "@web/views/pivot/pivot_view";
|
||||
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
|
||||
|
||||
export const forecastPivotView = {
|
||||
...pivotView,
|
||||
SearchModel: ForecastSearchModel,
|
||||
};
|
||||
|
||||
registry.category("views").add("forecast_pivot", forecastPivotView);
|
||||
86
frontend/crm/static/src/views/forecast_search_model.js
Normal file
86
frontend/crm/static/src/views/forecast_search_model.js
Normal file
@@ -0,0 +1,86 @@
|
||||
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.
|
||||
*/
|
||||
|
||||
export class ForecastSearchModel extends SearchModel {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
exportState() {
|
||||
const state = super.exportState();
|
||||
state.forecast = {
|
||||
forecastStart: this.forecastStart,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_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]);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {string} forecastField
|
||||
* @returns {string}
|
||||
*/
|
||||
_getForecastStart(forecastField) {
|
||||
if (!this.forecastStart) {
|
||||
const { type } = this.searchViewFields[forecastField];
|
||||
const groupBy = this.groupBy;
|
||||
const firstForecastGroupBy = groupBy.find((gb) => gb.includes(forecastField));
|
||||
let granularity = "month";
|
||||
if (firstForecastGroupBy) {
|
||||
granularity = firstForecastGroupBy.split(":")[1] || "month";
|
||||
} else if (groupBy.length) {
|
||||
granularity = "day";
|
||||
}
|
||||
const startDateTime = luxon.DateTime.now().startOf(granularity);
|
||||
this.forecastStart = type === "datetime" ? serializeDateTime(startDateTime) : serializeDate(startDateTime);
|
||||
}
|
||||
return this.forecastStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_importState(state) {
|
||||
super._importState(...arguments);
|
||||
if (state.forecast) {
|
||||
this.forecastStart = state.forecast.forecastStart;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_reset() {
|
||||
super._reset();
|
||||
this.forecastStart = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user