mirror of
https://github.com/bringout/oca-ocb-crm.git
synced 2026-04-22 19:32:11 +02:00
Initial commit: Crm packages
This commit is contained in:
commit
21a345b5b9
654 changed files with 418312 additions and 0 deletions
BIN
odoo-bringout-oca-ocb-crm/crm/static/src/img/autofill.gif
Normal file
BIN
odoo-bringout-oca-ocb-crm/crm/static/src/img/autofill.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
odoo-bringout-oca-ocb-crm/crm/static/src/img/generate-leads.gif
Normal file
BIN
odoo-bringout-oca-ocb-crm/crm/static/src/img/generate-leads.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
odoo-bringout-oca-ocb-crm/crm/static/src/img/mapview-toggle.gif
Normal file
BIN
odoo-bringout-oca-ocb-crm/crm/static/src/img/mapview-toggle.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
90
odoo-bringout-oca-ocb-crm/crm/static/src/js/tours/crm.js
Normal file
90
odoo-bringout-oca-ocb-crm/crm/static/src/js/tours/crm.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _t } from 'web.core';
|
||||
import { Markup } from 'web.utils';
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
tour.register('crm_tour', {
|
||||
url: "/web",
|
||||
rainbowManMessage: _t("Congrats, best of luck catching such big fish! :)"),
|
||||
sequence: 10,
|
||||
}, [tour.stepUtils.showAppsMenuItem(), {
|
||||
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',
|
||||
}, {
|
||||
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',
|
||||
}, {
|
||||
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='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"));
|
||||
},
|
||||
}, {
|
||||
trigger: ".ui-menu-item > a",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
}, {
|
||||
trigger: ".o_kanban_quick_create .o_kanban_add",
|
||||
content: Markup(_t("Now, <b>add your Opportunity</b> to your Pipeline.")),
|
||||
position: "bottom",
|
||||
}, {
|
||||
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]');
|
||||
},
|
||||
}, {
|
||||
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) ",
|
||||
}, {
|
||||
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");
|
||||
},
|
||||
}, {
|
||||
trigger: ".o_lead_opportunity_form .o_statusbar_status",
|
||||
content: _t("You can make your opportunity advance through your pipeline from here."),
|
||||
position: "bottom"
|
||||
}, {
|
||||
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");
|
||||
}
|
||||
}]);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/** @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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
25
odoo-bringout-oca-ocb-crm/crm/static/src/models/ir_model.js
Normal file
25
odoo-bringout-oca-ocb-crm/crm/static/src/models/ir_model.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** @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();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
76
odoo-bringout-oca-ocb-crm/crm/static/src/scss/crm.scss
Normal file
76
odoo-bringout-oca-ocb-crm/crm/static/src/scss/crm.scss
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.o_opportunity_kanban {
|
||||
.ribbon {
|
||||
&::before, &::after {
|
||||
display: none;
|
||||
}
|
||||
span {
|
||||
padding: 5px;
|
||||
font-size: small;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ribbon-top-right {
|
||||
margin-top: -9px;
|
||||
span {
|
||||
left: 12px;
|
||||
right: 30px;
|
||||
height: 25px;
|
||||
top: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.o_crm_team_member_kanban {
|
||||
.o_member_assignment div.oe_gauge {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/** @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) {
|
||||
effect.add({
|
||||
message,
|
||||
type: "rainbow_man",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useCheckRainbowman() {
|
||||
const component = useComponent();
|
||||
const orm = useService("orm");
|
||||
const effect = useService("effect");
|
||||
return checkRainbowmanMessage.bind(component, orm, effect);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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.
|
||||
* 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() {
|
||||
const recordID = this.__bm_handle__;
|
||||
const localData = this.model.__bm__.localData[recordID];
|
||||
const changes = localData._changes || {};
|
||||
|
||||
const needsSynchronizationEmail =
|
||||
changes.partner_email_update === undefined
|
||||
? localData.data.partner_email_update // original value
|
||||
: 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
|
||||
|
||||
if (
|
||||
needsSynchronizationEmail &&
|
||||
changes.email_from === undefined &&
|
||||
localData.data.email_from
|
||||
) {
|
||||
changes.email_from = localData.data.email_from;
|
||||
}
|
||||
if (needsSynchronizationPhone && changes.phone === undefined && localData.data.phone) {
|
||||
changes.phone = localData.data.phone;
|
||||
}
|
||||
if (!localData._changes && Object.keys(changes).length) {
|
||||
localData._changes = changes;
|
||||
}
|
||||
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 isSaved = await super.save(...arguments);
|
||||
if (changedStage && isSaved) {
|
||||
checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
|
||||
}
|
||||
return isSaved;
|
||||
}
|
||||
}
|
||||
|
||||
class CrmFormModel extends RelationalModel {
|
||||
setup(params, services) {
|
||||
this.effect = services.effect;
|
||||
super.setup(...arguments);
|
||||
}
|
||||
}
|
||||
CrmFormModel.Record = CrmFormRecord;
|
||||
CrmFormModel.services = [...RelationalModel.services, "effect"];
|
||||
|
||||
registry.category("views").add("crm_form", {
|
||||
...formView,
|
||||
Model: CrmFormModel,
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { KanbanModel } from "@web/views/kanban/kanban_model";
|
||||
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
|
||||
|
||||
export class CrmKanbanModel extends KanbanModel {
|
||||
setup(params, { orm, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
const succeeded = await super.moveRecord(...arguments);
|
||||
if (!succeeded) {
|
||||
return;
|
||||
}
|
||||
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.ormService, 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"];
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
CrmKanbanRenderer.template = "crm.CrmKanbanRenderer";
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/** @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";
|
||||
|
||||
export const crmKanbanView = {
|
||||
...kanbanView,
|
||||
ArchParser: CrmKanbanArchParser,
|
||||
// Makes it easier to patch
|
||||
Controller: class extends kanbanView.Controller {},
|
||||
Model: CrmKanbanModel,
|
||||
Renderer: CrmKanbanRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("crm_kanban", crmKanbanView);
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
/**
|
||||
* Configuration depending on the granularity:
|
||||
* @param {function} startOf function to get the start moment of the period from a moment
|
||||
* @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 moment.
|
||||
* {1} is the first index. {+1} is used for functions 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.isoWeekday(),
|
||||
},
|
||||
week: {
|
||||
startOf: (x) => x.startOf("isoWeek"),
|
||||
cycle: 1,
|
||||
cyclePos: (x) => 1,
|
||||
},
|
||||
month: {
|
||||
startOf: (x) => x.startOf("month"),
|
||||
cycle: 12,
|
||||
cyclePos: (x) => x.month() + 1,
|
||||
},
|
||||
quarter: {
|
||||
startOf: (x) => x.startOf("quarter"),
|
||||
cycle: 4,
|
||||
cyclePos: (x) => x.quarter(),
|
||||
},
|
||||
year: {
|
||||
startOf: (x) => x.startOf("year"),
|
||||
cycle: 1,
|
||||
cyclePos: (x) => 1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 the moment for the start of the period containing "now"
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_computeStart() {
|
||||
this.start = GRANULARITY_TABLE[this.granularity].startOf(moment());
|
||||
}
|
||||
/**
|
||||
* Compute the moment 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 = moment(this.start).add(fillTemporalPeriod, `${this.granularity}s`);
|
||||
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)
|
||||
*/
|
||||
_getFormattedServerDate(bound) {
|
||||
if (bound.isUTC() || this.field.type === "date") {
|
||||
return bound.clone().locale("en").format(FIELD_TYPE_TABLE[this.field.type].format);
|
||||
} else {
|
||||
return moment.utc(bound).locale("en").format(FIELD_TYPE_TABLE[this.field.type].format);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Object} configuration
|
||||
* @param {Array[]} [domain]
|
||||
* @param {boolean} [forceStartBound=true] whether this.start moment 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
|
||||
* 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) {
|
||||
fillTemporal.fill_to = this._getFormattedServerDate(
|
||||
moment(this.end).subtract(1, FIELD_TYPE_TABLE[this.field.type].minGranularity)
|
||||
);
|
||||
}
|
||||
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 moment. It must be greater
|
||||
* than start. Changes the default behavior of getContext forceFillingTo
|
||||
* (becomes true instead of false)
|
||||
*
|
||||
* @param {moment} end
|
||||
*/
|
||||
setEnd(end) {
|
||||
this.end = moment.max(this.start, end);
|
||||
this.computedEnd = false;
|
||||
}
|
||||
/**
|
||||
* sets the start of the period to the desired moment. It must be smaller than end
|
||||
*
|
||||
* @param {moment} start
|
||||
*/
|
||||
setStart(start) {
|
||||
this.start = moment.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`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,12 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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,25 @@
|
|||
/** @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";
|
||||
|
||||
export class ForecastKanbanColumnQuickCreate extends KanbanColumnQuickCreate {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get relatedFieldName() {
|
||||
const { granularity = "month" } = this.props.groupByField;
|
||||
const { description } = INTERVAL_OPTIONS[granularity];
|
||||
return sprintf(_t("Add next %s"), description.toLocaleLowerCase());
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Create column directly upon "unfolding" quick create.
|
||||
*/
|
||||
unfold() {
|
||||
this.props.onValidate();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { KanbanModel } from "@web/views/kanban/kanban_model";
|
||||
|
||||
export class ForecastKanbanModel extends KanbanModel {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Add fill_temporal context keys to the context before loading the groups.
|
||||
*/
|
||||
get context() {
|
||||
const context = super.context;
|
||||
if (!this.isForecastGroupBy()) {
|
||||
return context;
|
||||
}
|
||||
return this.fillTemporalPeriod.getContext({ context });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const forceRecompute = this.forceNextRecompute;
|
||||
this.forceNextRecompute = false;
|
||||
return this.model.fillTemporalService.getFillTemporalPeriod({
|
||||
modelName: this.resModel,
|
||||
field: {
|
||||
name,
|
||||
type,
|
||||
},
|
||||
granularity: granularity || "month",
|
||||
minGroups,
|
||||
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;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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 {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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.model.root.load();
|
||||
this.props.list.model.notify();
|
||||
}
|
||||
}
|
||||
|
||||
ForecastKanbanRenderer.template = "crm.ForecastKanbanRenderer";
|
||||
ForecastKanbanRenderer.components = {
|
||||
...KanbanRenderer.components,
|
||||
ForecastKanbanColumnQuickCreate,
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="crm.ForecastKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
|
||||
<KanbanColumnQuickCreate position="replace">
|
||||
<t t-if="isGroupedByForecastField()">
|
||||
<ForecastKanbanColumnQuickCreate
|
||||
folded="true"
|
||||
onFoldChange="() => {}"
|
||||
onValidate.bind="addForecastColumn"
|
||||
exampleData="exampleData"
|
||||
groupByField="props.list.groupByField"
|
||||
/>
|
||||
</t>
|
||||
<t t-else="">$0</t>
|
||||
</KanbanColumnQuickCreate>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
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";
|
||||
|
||||
export const forecastKanbanView = {
|
||||
...kanbanView,
|
||||
Model: ForecastKanbanModel,
|
||||
Renderer: ForecastKanbanRenderer,
|
||||
SearchModel: ForecastSearchModel,
|
||||
};
|
||||
|
||||
registry.category("views").add("forecast_kanban", forecastKanbanView);
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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,12 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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);
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { makeContext } from "@web/core/context";
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
exportState() {
|
||||
const state = super.exportState();
|
||||
state.forecast = {
|
||||
forecastStart: this.forecastStart,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_getDomain(params = {}) {
|
||||
const domain = super._getDomain(...arguments);
|
||||
const forecastField = this.globalContext.forecast_field;
|
||||
if (!forecastField) {
|
||||
return domain;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {string} forecastField
|
||||
* @returns {string}
|
||||
*/
|
||||
_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";
|
||||
if (firstForecastGroupBy) {
|
||||
granularity = firstForecastGroupBy.split(":")[1] || "month";
|
||||
} 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);
|
||||
}
|
||||
return this.forecastStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_importState(state) {
|
||||
super._importState(...arguments);
|
||||
if (state.forecast) {
|
||||
this.forecastStart = state.forecast.forecastStart;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_reset() {
|
||||
super._reset();
|
||||
this.forecastStart = null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue