19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,8 +1,11 @@
.o_field_analytic_distribution {
height: auto;
.analytic_distribution_placeholder {
height: 1.5em;
width: 20px;
color: grey;
opacity: 1;
}
.o_input_dropdown {
@ -41,8 +44,11 @@
}
.analytic_distribution_popup {
width: 400px;
min-width: 400px;
max-width: $o-form-sheet-min-width;
max-height: 50vh;
// this ensures that analytic distribution widget does not cover "new model" or "search more" modals
z-index: $zindex-modal - 1;
white-space: nowrap;
cursor: default;
@ -67,5 +73,12 @@
tr:hover {
outline: none !important;
}
.numeric_column_width {
max-width: 105px;
// width: 88px;
}
.w-20px {
width: 20px;
}
}
}

View file

@ -1,70 +1,84 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="analytic.AnalyticDistribution" owl="1">
<t t-name="analytic.AnalyticDistribution">
<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"/>
<TagsList tags="planSummaryTags()"/>
<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"/>
<span class="analytic_distribution_placeholder"><t t-if="!state.formattedData.length" t-esc="props.placeholder"/></span>
<span class="o_dropdown_button" />
<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">
<t t-name="analytic.AnalyticDistributionPopup">
<div class="popover analytic_distribution_popup dropdown-menu o-dropdown--menu show rounded py-0 overflow-x-hidden" 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>
<span t-if="allowSave" class="btn btn-link" t-on-click="onSaveNew" title="Save as new analytic distribution model">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>
<span class="btn o_button" t-on-click.stop="() => this.closeAnalyticEditor()" title="Close"><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}}">
<div class="popover-body p-2 table-responsive" style="max-width: 100vw;">
<span t-if="!allPlans.length">No analytic plans found</span>
<table t-else="" class="o_list_table table table-sm table-hover o_analytic_table mb-2 table-striped">
<t t-set="totals" t-value="planTotals()"/>
<thead>
<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 t-foreach="allPlans" t-as="plan" t-key="plan.id">
<t t-if="props.multi_edit">
<a t-if="state.update_plan[plan.column_name]" t-on-click="() => state.update_plan[plan.column_name] = false" href="#">Don't update</a>
<a t-else="" t-on-click="() => state.update_plan[plan.column_name] = true" href="#">Update</a>
<br/>
</t>
<span t-out="plan.name"/>
<t t-if="state.update_plan[plan.column_name]">(<span t-att-class="totals[plan.id].class" t-out="totals[plan.id].formattedValue"/>)</t>
</th>
<th class="numeric_column_width">Percentage</th>
<th t-if="valueColumnEnabled" class="numeric_column_width" t-out="props.record.fields[props.amount_field].string"/>
<th class="deleteColumn w-20px"/>
</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)"/>
</thead>
<tbody>
<tr t-foreach="state.formattedData" t-as="line" t-key="line.id" t-att-id="line_index" t-att-name="'line_' + line_index">
<Record t-props="recordProps(line)" t-slot-scope="data">
<t t-foreach="Object.keys(data.record.fields).filter((f) => f.startsWith('x_plan') || f == 'account_id')" t-as="field" t-key="field">
<td t-att-style="state.update_plan[field] ? '' : '--table-bg-state: var(--table-hover-bg);'">
<Field t-if="state.update_plan[field]"
id="field"
name="field"
record="data.record"
domain="data.record.fields[field].domain"
canOpen="false"
canCreate="false"
canCreateEdit="false"
canQuickCreate="false"/>
</td>
</t>
<td class="numeric_column_width">
<Field id="'percentage'" name="'percentage'" record="data.record"/>
</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 t-if="valueColumnEnabled" class="numeric_column_width">
<Field id="props.amount_field" name="props.amount_field" record="data.record"/>
</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 class="w-20px">
<span class="fa fa-trash-o cursor-pointer" t-on-click.stop="() => this.deleteLine(line_index)"/>
</td>
</tr>
</t>
</table>
</t>
<div tabindex="0" class="hidden-focus"/>
</Record>
</tr>
<tr>
<td t-on-click.stop.prevent="addLine" class="o_field_x2many_list_row_add" t-att-colspan="allPlans.length + 2 + valueColumnEnabled">
<a href="#" t-ref="addLineButton" tabindex="0">Add a Line</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</t>

View file

@ -0,0 +1,88 @@
import { registry } from "@web/core/registry";
import { ORM } from "@web/core/orm_service";
import { unique } from "@web/core/utils/arrays";
import { Deferred } from "@web/core/utils/concurrency";
class RequestBatcherORM extends ORM {
constructor() {
super();
this.searchReadBatches = {};
this.searchReadBatchId = 1;
this.batches = {};
}
/**
* @param {number[]} ids
* @param {any[]} keys
* @param {Function} callback
* @returns {Promise<any>}
*/
async batch(ids, keys, callback) {
const key = JSON.stringify(keys);
let batch = this.batches[key];
if (!batch) {
batch = {
deferred: new Deferred(),
scheduled: false,
ids: [],
};
this.batches[key] = batch;
}
batch.ids = unique([...batch.ids, ...ids]);
if (!batch.scheduled) {
batch.scheduled = true;
Promise.resolve().then(async () => {
delete this.batches[key];
let result;
try {
result = await callback(batch.ids);
} catch (e) {
return batch.deferred.reject(e);
}
batch.deferred.resolve(result);
});
}
return batch.deferred;
}
/**
* Entry point to batch "read" calls. If the `fields` and `resModel`
* arguments have already been called, the given ids are added to the
* previous list of ids to perform a single read call. Once the server
* responds, records are then dispatched to the callees based on the
* given ids arguments (kept in the closure).
*
* @param {string} resModel
* @param {number[]} resIds
* @param {string[]} fields
* @returns {Promise<Object[]>}
*/
async read(resModel, resIds, fields, kwargs) {
const records = await this.batch(resIds, ["read", resModel, fields, kwargs], (resIds) =>
super.read(resModel, resIds, fields, kwargs)
);
return records.filter((r) => resIds.includes(r.id));
}
}
export const batchedOrmService = {
async: [
"call",
"create",
"nameGet",
"read",
"formattedReadGroup",
"search",
"searchRead",
"unlink",
"webSearchRead",
"write",
],
start() {
return new RequestBatcherORM();
},
};
registry.category("services").add("batchedOrm", batchedOrmService);

View file

@ -0,0 +1,30 @@
import { SearchModel } from "@web/search/search_model";
const PLAN_REGEX = /^(?:x_)?(x_plan\d+_id|account_id)(_\d+)?$/;
export class AnalyticSearchModel extends SearchModel {
getSearchItems(predicate) {
let searchItems = super.getSearchItems(predicate);
const mapped = Map.groupBy(
searchItems.filter((f) => f.fieldName?.match(PLAN_REGEX)),
(f) => f.fieldName.match(PLAN_REGEX)[1],
);
searchItems = searchItems.filter(
(f) => !f.fieldName?.match(PLAN_REGEX) || mapped.has(f.fieldName)
);
searchItems.forEach((f) => {
if (f.fieldName && mapped.has(f.fieldName) && mapped.get(f.fieldName).length > 1) {
f.options = mapped.get(f.fieldName);
}
});
return searchItems;
}
toggleDateGroupBy(searchItemId, intervalId) {
if (typeof(intervalId) === "number") {
this.toggleSearchItem(intervalId);
} else {
super.toggleDateGroupBy(searchItemId, intervalId);
}
}
}

View file

@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { graphView } from "@web/views/graph/graph_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
export const analyticGraphView = {
...graphView,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_graph", analyticGraphView);

View file

@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
export const analyticKanbanView = {
...kanbanView,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_kanban", analyticKanbanView);

View file

@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
export const analyticListView = {
...listView,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_list", analyticListView);

View file

@ -0,0 +1,21 @@
import { PivotRenderer } from "@web/views/pivot/pivot_renderer";
export class AnalyticPivotRenderer extends PivotRenderer {
/*
* Override to avoid using incomplete groupByItems
*/
onGroupBySelected({ itemId, optionId }) {
if (typeof(optionId) === "number") {
itemId = optionId;
}
let searchItems = this.env.searchModel.getSearchItems(
(searchItem) =>
["groupBy", "dateGroupBy"].includes(searchItem.type) && !searchItem.custom
)
searchItems = [...searchItems, ...searchItems.flatMap((f) => f.options).filter((f) => typeof(f?.id) === "number")]
const { fieldName } = searchItems.find(({ id }) => id === itemId);
this.model.addGroupBy({ ...this.dropdown.cellInfo, fieldName, interval: optionId });
}
}

View file

@ -0,0 +1,12 @@
import { registry } from "@web/core/registry";
import { pivotView } from "@web/views/pivot/pivot_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
import { AnalyticPivotRenderer } from "@analytic/views/pivot/pivot_renderer";
export const analyticPivotView = {
...pivotView,
Renderer: AnalyticPivotRenderer,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_pivot", analyticPivotView);