mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 09:12:04 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
|
|
@ -0,0 +1,701 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService, useOwnedDialogs } from "@web/core/utils/hooks";
|
||||
import { evaluateExpr } from "@web/core/py_js/py";
|
||||
import { getNextTabableElement, getPreviousTabableElement } from "@web/core/utils/ui";
|
||||
import { usePosition } from "@web/core/position_hook";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
import { shallowEqual } from "@web/core/utils/arrays";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
|
||||
import { useOpenMany2XRecord } from "@web/views/fields/relational_utils";
|
||||
import { parseFloat as oParseFloat } from "@web/views/fields/parsers";
|
||||
import { formatPercentage } from "@web/views/fields/formatters";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
|
||||
const { Component, useState, useRef, useExternalListener, onWillUpdateProps, onWillStart, onPatched } = owl;
|
||||
|
||||
const PLAN_APPLICABILITY = {
|
||||
mandatory: _lt("Mandatory"),
|
||||
optional: _lt("Optional"),
|
||||
}
|
||||
const PLAN_STATUS = {
|
||||
invalid: _lt("Invalid"),
|
||||
ok: _lt("OK"),
|
||||
}
|
||||
export class AnalyticDistribution extends Component {
|
||||
setup(){
|
||||
this.orm = useService("orm");
|
||||
|
||||
this.state = useState({
|
||||
showDropdown: false,
|
||||
list: {},
|
||||
});
|
||||
|
||||
this.widgetRef = useRef("analyticDistribution");
|
||||
this.dropdownRef = useRef("analyticDropdown");
|
||||
this.mainRef = useRef("mainElement");
|
||||
usePosition(() => this.widgetRef.el, {
|
||||
popper: "analyticDropdown",
|
||||
});
|
||||
|
||||
this.nextId = 1;
|
||||
this.focusSelector = false;
|
||||
|
||||
onWillStart(this.willStart);
|
||||
onWillUpdateProps(this.willUpdate);
|
||||
onPatched(this.patched);
|
||||
|
||||
useExternalListener(window, "click", this.onWindowClick, true);
|
||||
|
||||
this.openTemplate = useOpenMany2XRecord({
|
||||
resModel: "account.analytic.distribution.model",
|
||||
activeActions: {
|
||||
create: true,
|
||||
edit: false,
|
||||
write: true,
|
||||
},
|
||||
isToMany: false,
|
||||
onRecordSaved: async (record) => {
|
||||
if (!this.props.record.model.multiEdit) {
|
||||
this.mainRef.el.focus();
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
if (!this.props.record.model.multiEdit) {
|
||||
this.mainRef.el.focus();
|
||||
}
|
||||
},
|
||||
fieldString: this.env._t("Analytic Distribution Template"),
|
||||
});
|
||||
this.allPlans = [];
|
||||
this.lastAccount = this.props.account_field && this.props.record.data[this.props.account_field] || false;
|
||||
this.lastProduct = this.props.product_field && this.props.record.data[this.props.product_field] || false;
|
||||
|
||||
this.selectCreateIsOpen = false;
|
||||
this.addDialog = useOwnedDialogs();
|
||||
this.onSearchMore = this._onSearchMore.bind(this);
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
async willStart() {
|
||||
if (this.editingRecord) {
|
||||
await this.fetchAllPlans(this.props);
|
||||
}
|
||||
await this.formatData(this.props);
|
||||
}
|
||||
|
||||
async willUpdate(nextProps) {
|
||||
// Unless force_applicability, Plans need to be retrieved again as the product or account might have changed
|
||||
// and thus different applicabilities apply
|
||||
// or a model applies that contains unavailable plans
|
||||
// This should only execute when these fields have changed, therefore we use the `_field` props.
|
||||
const valueChanged = JSON.stringify(this.props.value) !== JSON.stringify(nextProps.value);
|
||||
const currentAccount = this.props.account_field && this.props.record.data[this.props.account_field] || false;
|
||||
const currentProduct = this.props.product_field && this.props.record.data[this.props.product_field] || false;
|
||||
const accountChanged = !shallowEqual(this.lastAccount, currentAccount);
|
||||
const productChanged = !shallowEqual(this.lastProduct, currentProduct);
|
||||
if (valueChanged || accountChanged || productChanged) {
|
||||
if (!this.props.force_applicability) {
|
||||
await this.fetchAllPlans(nextProps);
|
||||
}
|
||||
this.lastAccount = accountChanged && currentAccount || this.lastAccount;
|
||||
this.lastProduct = productChanged && currentProduct || this.lastProduct;
|
||||
await this.formatData(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
patched() {
|
||||
this.focusToSelector();
|
||||
}
|
||||
|
||||
async formatData(nextProps) {
|
||||
const data = nextProps.value;
|
||||
const analytic_account_ids = Object.keys(data).map((id) => parseInt(id));
|
||||
const records = analytic_account_ids.length ? await this.fetchAnalyticAccounts([["id", "in", analytic_account_ids]]) : [];
|
||||
let widgetData = Object.assign({}, ...this.allPlans.map((plan) => ({[plan.id]: {...plan, distribution: []}})));
|
||||
records.map((record) => {
|
||||
if (!widgetData[record.root_plan_id[0]]) {
|
||||
// plans might not have been retrieved
|
||||
widgetData[record.root_plan_id[0]] = { distribution: [] }
|
||||
}
|
||||
widgetData[record.root_plan_id[0]].distribution.push({
|
||||
analytic_account_id: record.id,
|
||||
percentage: data[record.id],
|
||||
id: this.nextId++,
|
||||
group_id: record.root_plan_id[0],
|
||||
analytic_account_name: record.display_name,
|
||||
color: record.color,
|
||||
});
|
||||
});
|
||||
|
||||
this.state.list = widgetData;
|
||||
if (records.length < Object.keys(data).length) {
|
||||
// analytic accounts were not found for some keys in the json data, they may have been deleted
|
||||
// save the json without them
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
// ORM
|
||||
fetchPlansArgs(nextProps) {
|
||||
let args = {};
|
||||
if (this.props.business_domain_compute) {
|
||||
args['business_domain'] = evaluateExpr(this.props.business_domain_compute, this.props.record.evalContext);
|
||||
}
|
||||
if (this.props.business_domain) {
|
||||
args['business_domain'] = this.props.business_domain;
|
||||
}
|
||||
if (this.props.product_field && this.props.record.data[this.props.product_field]) {
|
||||
args['product'] = this.props.record.data[this.props.product_field][0];
|
||||
}
|
||||
if (this.props.account_field && this.props.record.data[this.props.account_field]) {
|
||||
args['account'] = this.props.record.data[this.props.account_field][0];
|
||||
}
|
||||
if (this.props.force_applicability) {
|
||||
args['applicability'] = this.props.force_applicability;
|
||||
}
|
||||
const existing_account_ids = Object.keys(nextProps.value).map((i) => parseInt(i));
|
||||
if (existing_account_ids.length) {
|
||||
args['existing_account_ids'] = existing_account_ids;
|
||||
}
|
||||
if (this.props.record.data.company_id) {
|
||||
args['company_id'] = this.props.record.data.company_id[0];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async fetchAllPlans(nextProps) {
|
||||
// TODO: Optimize to execute once for all records when `force_applicability` is set
|
||||
const argsPlan = this.fetchPlansArgs(nextProps);
|
||||
this.allPlans = await this.orm.call("account.analytic.plan", "get_relevant_plans", [], argsPlan);
|
||||
}
|
||||
|
||||
async fetchAnalyticAccounts(domain, limit=null) {
|
||||
const args = {
|
||||
domain: domain,
|
||||
fields: ["id", "display_name", "root_plan_id", "color"],
|
||||
context: [],
|
||||
}
|
||||
if (limit) {
|
||||
args['limit'] = limit;
|
||||
}
|
||||
if (domain.length === 1 && domain[0][0] === "id") {
|
||||
//batch these orm calls
|
||||
return await this.props.record.model.orm.read("account.analytic.account", domain[0][2], args.fields, {});
|
||||
}
|
||||
return await this.orm.call("account.analytic.account", "search_read", [], args);
|
||||
}
|
||||
|
||||
// Autocomplete
|
||||
sourcesAnalyticAccount(groupId) {
|
||||
return [this.optionsSourceAnalytic(groupId)];
|
||||
}
|
||||
|
||||
optionsSourceAnalytic(groupId) {
|
||||
return {
|
||||
placeholder: this.env._t("Loading..."),
|
||||
options:(searchTerm) => this.loadOptionsSourceAnalytic(groupId, searchTerm),
|
||||
};
|
||||
}
|
||||
|
||||
analyticAccountDomain(groupId=null) {
|
||||
let domain = [['id', 'not in', this.existingAnalyticAccountIDs]];
|
||||
if (this.props.record.data.company_id){
|
||||
domain.push(
|
||||
'|',
|
||||
['company_id', '=', this.props.record.data.company_id[0]],
|
||||
['company_id', '=', false]
|
||||
);
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
domain.push(['root_plan_id', '=', groupId]);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
searchAnalyticDomain(searchTerm) {
|
||||
return [
|
||||
'|',
|
||||
["name", "ilike", searchTerm],
|
||||
'|',
|
||||
['code', 'ilike', searchTerm],
|
||||
['partner_id', 'ilike', searchTerm],
|
||||
];
|
||||
}
|
||||
|
||||
async loadOptionsSourceAnalytic(groupId, searchTerm) {
|
||||
const searchLimit = 6;
|
||||
|
||||
const records = await this.fetchAnalyticAccounts([
|
||||
...this.analyticAccountDomain(groupId),
|
||||
...this.searchAnalyticDomain(searchTerm)], searchLimit + 1);
|
||||
|
||||
let options = records.map((result) => ({
|
||||
value: result.id,
|
||||
label: result.display_name,
|
||||
group_id: result.root_plan_id[0],
|
||||
color: result.color,
|
||||
}));
|
||||
|
||||
if (searchLimit < records.length) {
|
||||
options.push({
|
||||
label: this.env._t("Search More..."),
|
||||
action: (editedTag) => this.onSearchMore(searchTerm, editedTag),
|
||||
classList: "o_m2o_dropdown_option o_m2o_dropdown_option_search_more",
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
options.push({
|
||||
label: this.env._t("No Analytic Accounts for this plan"),
|
||||
classList: "o_m2o_no_result",
|
||||
unselectable: true,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async _onSearchMore(searchTerm, editedTag) {
|
||||
let dynamicFilters = [];
|
||||
if (searchTerm.length) {
|
||||
dynamicFilters = [
|
||||
{
|
||||
description: sprintf(this.env._t("Quick search: %s"), searchTerm),
|
||||
domain: this.searchAnalyticDomain(searchTerm),
|
||||
},
|
||||
];
|
||||
}
|
||||
this.selectCreateIsOpen = true;
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: this.env._t("Search: Analytic Account"),
|
||||
noCreate: true,
|
||||
multiSelect: true,
|
||||
resModel: 'account.analytic.account',
|
||||
context: {
|
||||
tree_view_ref: "analytic.view_account_analytic_account_list_select",
|
||||
},
|
||||
domain: this.analyticAccountDomain(editedTag.group_id),
|
||||
dynamicFilters: dynamicFilters,
|
||||
onSelected: async (resIds) => {
|
||||
const analytic_accounts = await this.fetchAnalyticAccounts([["id", "in", resIds]]);
|
||||
// modify the edited tag
|
||||
editedTag.analytic_account_id = analytic_accounts[0].id;
|
||||
editedTag.analytic_account_name = analytic_accounts[0].display_name;
|
||||
this.setFocusSelector(`.tag_${editedTag.id} .o_analytic_percentage`);
|
||||
if (analytic_accounts.length > 1) {
|
||||
const planId = editedTag.group_id;
|
||||
// remove the autofill line
|
||||
this.list[planId].distribution = this.list[planId].distribution.filter((t) => !!t.analytic_account_id);
|
||||
for (const account of analytic_accounts.slice(1)) {
|
||||
// add new tags
|
||||
const tag = this.newTag(planId);
|
||||
tag.analytic_account_id = account.id;
|
||||
tag.analytic_account_name = account.display_name;
|
||||
this.list[planId].distribution.push(tag);
|
||||
}
|
||||
}
|
||||
this.autoFill();
|
||||
},
|
||||
onCreateEdit: () => {},
|
||||
}, {
|
||||
onClose: () => {
|
||||
if (!editedTag.analytic_account_id) {
|
||||
this.setFocusSelector(`.tag_${editedTag.id} .o_analytic_account_name`);
|
||||
this.focusToSelector();
|
||||
}
|
||||
this.selectCreateIsOpen = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
autoCompleteInputChanged(distTag, inputValue) {
|
||||
if (inputValue === "" && distTag.analytic_account_id) {
|
||||
this.deleteTag(distTag.id, distTag.group_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Editing Distributions
|
||||
async onSelect(option, params, tag) {
|
||||
if (option.action) {
|
||||
return option.action(tag);
|
||||
}
|
||||
const selected_option = Object.getPrototypeOf(option);
|
||||
tag.analytic_account_id = parseInt(selected_option.value);
|
||||
tag.analytic_account_name = selected_option.label;
|
||||
tag.color = selected_option.color;
|
||||
this.setFocusSelector(`.tag_${tag.id} .o_analytic_percentage`);
|
||||
this.autoFill();
|
||||
}
|
||||
|
||||
async percentageChanged(dist_tag, ev) {
|
||||
dist_tag.percentage = this.parse(ev.target.value);
|
||||
this.autoFill();
|
||||
}
|
||||
|
||||
deleteTag(id, fromGroup) {
|
||||
// find the next tag to focus to before deleting the tag
|
||||
const allTags = this.listFlat;
|
||||
const currentTagIndex = allTags.findIndex((t) => t.id === id);
|
||||
const nextTag = allTags[(currentTagIndex + 1) % allTags.length];
|
||||
// remove the tag from the groups distribution
|
||||
this.list[fromGroup].distribution = this.list[fromGroup].distribution.filter((dist_tag) => dist_tag.id != id);
|
||||
if (!this.isDropdownOpen){
|
||||
this.save();
|
||||
} else {
|
||||
this.setFocusSelector(`.tag_${nextTag.id} .o_analytic_account_name`);
|
||||
this.autoFill();
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
get tags() {
|
||||
return this.listReady.map((dist_tag) => ({
|
||||
id: dist_tag.id,
|
||||
text: `${dist_tag.analytic_account_name}${dist_tag.percentage > 99.99 && dist_tag.percentage < 100.01 ? "" : " " + this.formatPercentage(dist_tag.percentage)}`,
|
||||
colorIndex: dist_tag.color,
|
||||
group_id: dist_tag.group_id,
|
||||
onClick: (ev) => this.tagClicked(ev, dist_tag.id),
|
||||
onDelete: this.editingRecord ? () => this.deleteTag(dist_tag.id, dist_tag.group_id) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
get listForJson() {
|
||||
let res = {};
|
||||
this.listReady.map(({analytic_account_id, percentage}) => {
|
||||
res[parseInt(analytic_account_id)] = percentage;
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
get firstIncompletePlanId() {
|
||||
for (const group_id in this.list) {
|
||||
if (this.groupStatus(group_id) == "invalid") {
|
||||
return group_id;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get existingAnalyticAccountIDs() {
|
||||
return this.listFlat.filter((i) => !!i.analytic_account_id).map((i) => i.analytic_account_id);
|
||||
}
|
||||
|
||||
get listReady() {
|
||||
return this.listFlat.filter((dist_tag) => this.tagIsReady(dist_tag));
|
||||
}
|
||||
|
||||
get listFlat() {
|
||||
return Object.values(this.list).flatMap((g) => g.distribution);
|
||||
}
|
||||
|
||||
get list() {
|
||||
return this.state.list;
|
||||
}
|
||||
|
||||
get sortedList() {
|
||||
return Object.values(this.list).sort((a, b) => {
|
||||
const aApp = a.applicability,
|
||||
bApp = b.applicability;
|
||||
return aApp > bApp ? 1 : aApp < bApp ? -1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
get allowSave() {
|
||||
if (this.firstIncompletePlanId > 0) {
|
||||
return false;
|
||||
}
|
||||
return this.props.allow_save;
|
||||
}
|
||||
|
||||
get editingRecord() {
|
||||
return !this.props.readonly;
|
||||
}
|
||||
|
||||
get isDropdownOpen() {
|
||||
return this.state.showDropdown && !!this.dropdownRef.el;
|
||||
}
|
||||
|
||||
statusDescription(group_id) {
|
||||
const group = this.list[group_id];
|
||||
const applicability = PLAN_APPLICABILITY[group.applicability];
|
||||
const status = PLAN_STATUS[this.groupStatus(group_id)];
|
||||
return `${applicability} - ${status} ${this.formatPercentage(this.sumByGroup(group_id))}`;
|
||||
}
|
||||
|
||||
groupStatus(id) {
|
||||
const group = this.list[id];
|
||||
if (group.applicability === 'mandatory') {
|
||||
const sum = this.sumByGroup(id);
|
||||
if (sum < 99.99 || sum >= 100.01) {
|
||||
return 'invalid';
|
||||
}
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
listReadyByGroup(id) {
|
||||
return this.list[id].distribution.filter((tag) => this.tagIsReady(tag));
|
||||
}
|
||||
|
||||
tagIsReady({analytic_account_id, percentage}) {
|
||||
return !!analytic_account_id;
|
||||
}
|
||||
|
||||
sumByGroup(id) {
|
||||
return this.listReadyByGroup(id).reduce((prev, next) => prev + (parseFloat(next.percentage) || 0), 0);
|
||||
}
|
||||
|
||||
remainderByGroup(id) {
|
||||
return 100 - Math.min(this.sumByGroup(id), 100);
|
||||
}
|
||||
|
||||
// actions
|
||||
newTag(group_id) {
|
||||
return {
|
||||
id: this.nextId++,
|
||||
group_id: group_id,
|
||||
analytic_account_id: null,
|
||||
analytic_account_name: "",
|
||||
percentage: this.remainderByGroup(group_id),
|
||||
color: this.list[group_id].color,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is typically called when opening the popup and after any change to the distribution.
|
||||
* The remainder, will be placed in the first tag with 0%.
|
||||
* It adds an empty tag allowing users to continue the distribution (replaced 'Add a Line').
|
||||
* Where an empty tag exists, the percentage is updated.
|
||||
*/
|
||||
autoFill() {
|
||||
for (const group of this.allPlans.filter((p) => p.all_account_count > 0)) {
|
||||
// update the first unmodified tag containing 0%
|
||||
const tagToUpdate = this.list[group.id].distribution.find((t) => t.percentage == 0);
|
||||
if (tagToUpdate) {
|
||||
tagToUpdate.percentage = this.remainderByGroup(group.id);
|
||||
}
|
||||
// a tag with no analytic account must always be added / updated
|
||||
const emptyTag = this.list[group.id].distribution.find((t) => !t.analytic_account_id);
|
||||
if (emptyTag) {
|
||||
emptyTag.percentage = this.remainderByGroup(group.id);
|
||||
} else {
|
||||
this.list[group.id].distribution.push(this.newTag(group.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
for (const group_id in this.list){
|
||||
this.list[group_id].distribution = this.listReadyByGroup(group_id);
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
const currentDistribution = this.listForJson;
|
||||
await this.props.update(currentDistribution);
|
||||
}
|
||||
|
||||
onSaveNew() {
|
||||
const { record, product_field, account_field } = this.props;
|
||||
this.openTemplate({
|
||||
resId: false,
|
||||
context: {
|
||||
default_analytic_distribution: this.listForJson,
|
||||
default_partner_id: record.data['partner_id'] ? record.data['partner_id'][0] : undefined,
|
||||
default_product_id: product_field ? record.data[product_field][0] : undefined,
|
||||
default_account_prefix: account_field ? record.data[account_field][1].substr(0, 3) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
this.closeAnalyticEditor();
|
||||
}
|
||||
|
||||
forceCloseEditor() {
|
||||
// focus to the main Element but the dropdown should not open
|
||||
this.preventOpen = true;
|
||||
this.closeAnalyticEditor();
|
||||
this.mainRef.el.focus();
|
||||
this.preventOpen = false;
|
||||
}
|
||||
|
||||
closeAnalyticEditor() {
|
||||
this.cleanUp();
|
||||
this.save();
|
||||
this.state.showDropdown = false;
|
||||
}
|
||||
|
||||
async openAnalyticEditor() {
|
||||
if (!this.allPlans.length) {
|
||||
await this.fetchAllPlans(this.props);
|
||||
await this.formatData(this.props);
|
||||
}
|
||||
this.autoFill();
|
||||
const incompletePlan = this.firstIncompletePlanId;
|
||||
this.setFocusSelector(incompletePlan ? `#plan_${incompletePlan} .incomplete`: ".analytic_json_popup");
|
||||
this.state.showDropdown = true;
|
||||
}
|
||||
|
||||
tagClicked(ev, id) {
|
||||
if (this.editingRecord && !this.isDropdownOpen) {
|
||||
this.openAnalyticEditor();
|
||||
}
|
||||
if (this.isDropdownOpen) {
|
||||
this.setFocusSelector(`.tag_${id} .o_analytic_percentage`);
|
||||
this.focusToSelector();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
// Focus
|
||||
onMainElementFocus(ev) {
|
||||
if (!this.isDropdownOpen && !this.preventOpen) {
|
||||
this.openAnalyticEditor();
|
||||
}
|
||||
}
|
||||
|
||||
focusToSelector() {
|
||||
if (!!this.focusSelector && this.isDropdownOpen) {
|
||||
this.focus(this.adjacentElementToFocus("next", this.dropdownRef.el.querySelector(this.focusSelector)));
|
||||
}
|
||||
this.focusSelector = false;
|
||||
}
|
||||
|
||||
setFocusSelector(selector) {
|
||||
this.focusSelector = selector;
|
||||
}
|
||||
|
||||
adjacentElementToFocus(direction, el = null) {
|
||||
if (!this.isDropdownOpen) {
|
||||
return null;
|
||||
}
|
||||
if (!el) {
|
||||
el = this.dropdownRef.el;
|
||||
}
|
||||
return direction == "next" ? getNextTabableElement(el) : getPreviousTabableElement(el);
|
||||
}
|
||||
|
||||
focusAdjacent(direction) {
|
||||
const elementToFocus = this.adjacentElementToFocus(direction);
|
||||
if (elementToFocus){
|
||||
this.focus(elementToFocus);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
focus(el) {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (["INPUT", "TEXTAREA"].includes(el.tagName)) {
|
||||
if (el.selectionStart) {
|
||||
el.selectionStart = 0;
|
||||
el.selectionEnd = el.value.length;
|
||||
}
|
||||
el.select();
|
||||
}
|
||||
}
|
||||
|
||||
// Keys and Clicks
|
||||
onWidgetKeydown(ev) {
|
||||
if (!this.editingRecord) {
|
||||
return;
|
||||
}
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
switch (hotkey) {
|
||||
case "enter":
|
||||
case "tab": {
|
||||
if (this.isDropdownOpen) {
|
||||
this.focusAdjacent("next") || this.forceCloseEditor();
|
||||
break;
|
||||
};
|
||||
return;
|
||||
}
|
||||
case "shift+tab": {
|
||||
if (this.isDropdownOpen) {
|
||||
this.focusAdjacent("previous") || this.forceCloseEditor();
|
||||
break;
|
||||
};
|
||||
return;
|
||||
}
|
||||
case "escape": {
|
||||
if (this.isDropdownOpen) {
|
||||
this.forceCloseEditor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
case "arrowdown": {
|
||||
if (!this.isDropdownOpen) {
|
||||
this.onMainElementFocus();
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
onWindowClick(ev) {
|
||||
if (this.isDropdownOpen
|
||||
&& this.dropdownRef.el && !this.dropdownRef.el.contains(ev.target)
|
||||
&& !this.widgetRef.el.contains(ev.target)
|
||||
&& !this.selectCreateIsOpen) {
|
||||
this.forceCloseEditor();
|
||||
}
|
||||
}
|
||||
|
||||
// formatters and parsers
|
||||
parse(value) {
|
||||
try {
|
||||
return typeof value === 'string' || value instanceof String ? oParseFloat(value.replace('%', '')) : value;
|
||||
} catch (_error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
formatPercentage(value) {
|
||||
return formatPercentage(value / 100, { digits: [false, this.props.record.data.analytic_precision || 2] });
|
||||
}
|
||||
}
|
||||
AnalyticDistribution.template = "analytic.AnalyticDistribution";
|
||||
AnalyticDistribution.supportedTypes = ["char", "text"];
|
||||
AnalyticDistribution.components = {
|
||||
AutoComplete,
|
||||
TagsList,
|
||||
}
|
||||
|
||||
AnalyticDistribution.fieldDependencies = {
|
||||
analytic_precision: { type: 'integer' },
|
||||
}
|
||||
AnalyticDistribution.props = {
|
||||
...standardFieldProps,
|
||||
business_domain: { type: String, optional: true },
|
||||
account_field: { type: String, optional: true },
|
||||
product_field: { type: String, optional: true },
|
||||
business_domain_compute: { type: String, optional: true },
|
||||
force_applicability: { type: String, optional: true },
|
||||
allow_save: { type: Boolean },
|
||||
}
|
||||
AnalyticDistribution.extractProps = ({ field, attrs }) => {
|
||||
return {
|
||||
business_domain: attrs.options.business_domain,
|
||||
account_field: attrs.options.account_field,
|
||||
product_field: attrs.options.product_field,
|
||||
business_domain_compute: attrs.business_domain_compute,
|
||||
force_applicability: attrs.options.force_applicability,
|
||||
allow_save: !Boolean(attrs.options.disable_save),
|
||||
};
|
||||
};
|
||||
|
||||
registry.category("fields").add("analytic_distribution", AnalyticDistribution);
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
.o_field_analytic_distribution {
|
||||
|
||||
.analytic_distribution_placeholder {
|
||||
height: 1.5em;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.o_input_dropdown {
|
||||
flex: 1 0 20px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.o_dropdown_button {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.o_dropdown_button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin: 1px 2px 1px 0;
|
||||
border: none;
|
||||
padding-left: 0.6em;
|
||||
padding-right: 0.6em;
|
||||
min-width: unset;
|
||||
|
||||
.o_delete {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.o_tag_badge_text {
|
||||
@include o-text-overflow(inline-block);
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.analytic_distribution_popup {
|
||||
width: 400px;
|
||||
max-height: 50vh;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
|
||||
.o_input {
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_analytic_account_name {
|
||||
width: 320px !important;
|
||||
}
|
||||
.o_analytic_percentage {
|
||||
width: 45px !important;
|
||||
}
|
||||
.o_analytic_status_invalid {
|
||||
background-color: $o-danger;
|
||||
margin-left:8px;
|
||||
}
|
||||
.o_analytic_status_ok {
|
||||
background-color: $o-success;
|
||||
margin-left:8px;
|
||||
}
|
||||
tr:hover {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="analytic.AnalyticDistribution" owl="1">
|
||||
<div class="o_field_tags d-inline-flex flex-wrap mw-100" t-att-class="{'o_tags_input o_input': !props.readonly}" t-ref="analyticDistribution" t-on-keydown="onWidgetKeydown">
|
||||
<TagsList tags="this.tags"/>
|
||||
<div t-if="!props.readonly" class="o_input_dropdown d-inline-flex w-100" tabindex="0" t-ref="mainElement" t-on-focus="onMainElementFocus" t-on-click="onMainElementFocus">
|
||||
<span class="analytic_distribution_placeholder"/>
|
||||
<a role="button" class="o_dropdown_button" draggable="false"/>
|
||||
<t t-call="analytic.AnalyticDistributionPopup"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="analytic.AnalyticDistributionPopup" owl="1">
|
||||
<div class="analytic_distribution_popup o-dropdown-menu show rounded py-0" t-if="state.showDropdown" t-ref="analyticDropdown">
|
||||
<div class="popover-header sticky-top">
|
||||
<div class="d-flex">
|
||||
<div class="h5 mt-2 me-auto">
|
||||
Analytic
|
||||
<span t-if="tags.length and allowSave" class="btn btn-link" t-on-click="onSaveNew">New Model</span>
|
||||
</div>
|
||||
<div class="popupButtons">
|
||||
<span class="o_button ms-2 cursor-pointer" t-on-click.stop="() => this.closeAnalyticEditor()"><span class="fa fa-close"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<span t-if="!sortedList.length">No plans available</span>
|
||||
<t t-foreach="sortedList" t-as="plan" t-key="plan.id">
|
||||
<table class="o_list_table table table-sm table-hover o_analytic_table mb-2 table-borderless" t-attf-id="plan_{{plan.id}}">
|
||||
<tr class="border-bottom">
|
||||
<th class="o_analytic_account_name">
|
||||
<t t-esc="plan.name"/>
|
||||
<t t-if="plan.account_count === 0"> (no accounts)</t>
|
||||
<span t-if="plan.applicability === 'mandatory'" t-attf-class="o_status d-inline-block o_analytic_status_{{groupStatus(plan.id)}}" t-att-title="statusDescription(plan.id)"/>
|
||||
</th>
|
||||
</tr>
|
||||
<t t-foreach="plan.distribution" t-as="dist_tag" t-key="dist_tag.id">
|
||||
<tr t-attf-class="{{tagIsReady(dist_tag) and 'ready' or !!dist_tag.analytic_account_id and 'to_remove' or 'incomplete'}} tag_{{dist_tag.id}}">
|
||||
<td class="o_analytic_account_name">
|
||||
<AutoComplete
|
||||
id="dist_tag.id.toString()"
|
||||
placeholder="'Search Analytic Account'"
|
||||
value="dist_tag.analytic_account_name"
|
||||
sources="sourcesAnalyticAccount(plan.id)"
|
||||
autoSelect="true"
|
||||
onSelect.alike="(option, params) => this.onSelect(option, params, dist_tag)"
|
||||
onChange.alike="({inputValue}) => this.autoCompleteInputChanged(dist_tag, inputValue)"/>
|
||||
</td>
|
||||
<td class="o_analytic_percentage">
|
||||
<input
|
||||
class="o_input"
|
||||
inputmode="numeric"
|
||||
type="text"
|
||||
t-att-value="formatPercentage(dist_tag.percentage)"
|
||||
t-on-click.stop=""
|
||||
t-on-change="(ev) => this.percentageChanged(dist_tag, ev)"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="dist_tag.analytic_account_id" class="fa fa-trash-o cursor-pointer" t-on-click.stop="() => this.deleteTag(dist_tag.id, dist_tag.group_id)"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</table>
|
||||
</t>
|
||||
<div tabindex="0" class="hidden-focus"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Loading…
Add table
Add a link
Reference in a new issue