19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,19 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="project.ProjectMilestone" t-inherit-mode="extension" owl="1">
<t t-inherit="project.ProjectMilestone" t-inherit-mode="extension">
<xpath expr="//t[@t-esc='milestone.name']" position="replace">
<span>
<t t-esc="milestone.name"/>
<span t-if="milestone.allow_billable &amp;&amp; milestone.quantity_percentage &amp;&amp; !milestone.sale_line_name" class="fst-italic text-muted">
<span t-if="milestone.allow_billable &amp;&amp; milestone.quantity_percentage &amp;&amp; !milestone.sale_line_display_name" class="fst-italic text-muted">
(<t t-esc="(100 * milestone.quantity_percentage).toFixed(2)"/>%)
</span>
</span>
<span t-if="milestone.allow_billable" t-attf-class="fst-italic {{state.colorClass || 'text-muted'}} ms-2">
<t t-if="milestone.sale_line_name" t-esc="milestone.sale_line_name"/>
<span t-if="milestone.quantity_percentage &amp;&amp; milestone.sale_line_name">
<div t-if="milestone.allow_billable" t-attf-class="fst-italic small {{state.colorClass and ' opacity-75' || 'text-muted'}}">
<t t-if="milestone.sale_line_display_name" t-esc="milestone.sale_line_display_name"/>
<span t-if="milestone.quantity_percentage &amp;&amp; milestone.sale_line_display_name">
(<t t-esc="(100 * milestone.quantity_percentage).toFixed(2)"/>%)
</span>
</span>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,16 @@
import { patch } from "@web/core/utils/patch";
import { ProjectProfitability } from "@project/components/project_right_side_panel/components/project_profitability";
import { ProjectProfitabilitySection } from "@sale_project/components/project_right_side_panel/components/project_profitability_section";
patch(ProjectProfitability, {
props: {
...ProjectProfitability.props,
projectId: Number,
context: Object,
},
components: {
...ProjectProfitability.components,
ProjectProfitabilitySection,
},
});

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="sale_project.ProjectProfitability" t-inherit="project.ProjectProfitability" t-inherit-mode="extension">
<xpath expr="//tr[hasclass('revenue_section')]" position="replace">
<ProjectProfitabilitySection
revenue="revenue"
labels="props.labels"
formatMonetary="props.formatMonetary.bind(this)"
onProjectActionClick="props.onProjectActionClick.bind(this)"
onClick="(params) => this.props.onProjectActionClick(params)"
projectId="this.props.projectId"
context="this.props.context"
/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,81 @@
import { useService } from "@web/core/utils/hooks";
import { Component, useState } from "@odoo/owl";
import { formatFloat, formatFloatTime } from "@web/views/fields/formatters";
export class ProjectProfitabilitySection extends Component {
static props = {
revenue: Object,
labels: Object,
formatMonetary: Function,
onProjectActionClick: Function,
onClick: Function,
projectId: Number,
context: Object,
};
static template = "sale_project.ProjectProfitabilitySection";
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.state = useState({
isFolded: true,
displayLoadMore: null,
});
this.sale_items = [];
}
get revenue() {
return this.props.revenue;
}
async toggleSaleItems() {
if (this.state.displayLoadMore === null) {
// first time the section is unfold, load the 5 first items.
await this.onLoadMoreClick()
}
// the state change is done at the end to ensure the loaded data are present when the component is rendered
this.state.isFolded = !this.state.isFolded;
}
formatValue(value, unit) {
return unit === "Hours" ? formatFloatTime(value) : formatFloat(value);
}
_getOrmValue(offset, section_id) {
return {
function: "get_sale_items_data",
args: [this.props.projectId, offset, 5, true, section_id],
};
}
async onLoadMoreClick() {
const offset = this.sale_items.length;
const orm_value = this._getOrmValue(offset, this.props.revenue.id);
const newItems = await this.orm.call(
"project.project",
orm_value.function,
orm_value.args,
{
context: this.props.context,
}
);
this.sale_items = [...this.sale_items, ...newItems.sol_items];
this.state.displayLoadMore = newItems.displayLoadMore;
this.render();
}
async onSaleItemActionClick(params) {
if (params.resId && params.type !== "object") {
const action = await this.actionService.loadAction(params.name, this.props.context);
this.actionService.doAction({
...action,
res_id: params.resId,
views: [[false, "form"]],
});
} else {
this.props.onProjectActionClick(params);
}
}
}

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="sale_project.ProjectProfitabilitySection">
<tr class="opacity-trigger-hover" t-att-class="{ 'table-active': !state.isFolded }">
<t t-set="revenue_label" t-value="props.labels[revenue.id] or revenue.id"/>
<td>
<div class="position-relative d-flex gap-1">
<t t-if="this.props.revenue.isSectionFoldable">
<button class="o_group_caret btn btn-link d-flex gap-1 flex-grow-1 p-0 text-reset text-start" t-on-click="() => this.toggleSaleItems()">
<i t-attf-class="fa fa-fw #{state.isFolded ? 'fa-caret-right' : 'fa-caret-down'} mt-1"/>
<span t-out="revenue_label"/>
</button>
</t>
<t t-esc="revenue_label" t-else=""/>
<a
class="revenue_section btn btn-link my-n2 py-2 text-action"
t-if="revenue.action" href="#"
t-on-click="() => this.props.onClick(revenue.action)"
t-att-class="{ 'ms-auto': env.isSmall, 'opacity-0': !env.isSmall and state.isFolded, 'opacity-100-hover': !env.isSmall }"
aria-label="Internal link"
data-tooltip="Internal link"
>
<i class="oi oi-arrow-right"/>
</a>
</div>
</td>
<td t-attf-class="text-end {{ revenue.invoiced + revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced + revenue.to_invoice)"/></td>
<td t-attf-class="text-end {{ revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.to_invoice)"/></td>
<td t-attf-class="text-end {{ revenue.invoiced === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced)"/></td>
</tr>
<t t-if="!state.isFolded and this.props.revenue.isSectionFoldable">
<tr class="table-active">
<td colspan="4" class="p-0 m-0">
<table class="o_rightpanel_subtable table table-sm table-borderless table-hover mb-0 border-bottom">
<thead>
<tr>
<th class="ps-4">Sales Order Items</th>
<th class="text-end">Sold</th>
<th class="text-end">Delivered</th>
<th class="text-end">Invoiced</th>
</tr>
</thead>
<tbody>
<tr t-foreach="sale_items" t-as="sale_item" t-key="sale_item.id">
<t t-set="uom_name" t-value="sale_item.product_uom_id and sale_item.product_uom_id[1]"/>
<td class="ps-4">
<a t-if="sale_item.action" href="#" t-on-click="() => this.onSaleItemActionClick(sale_item.action)">
<t t-esc="sale_item.display_name"/>
</a>
<t t-else="" t-esc="sale_item.display_name"/>
</td>
<td class="text-end">
<div class="d-flex d-md-block flex-column">
<t t-esc="formatValue(sale_item.product_uom_qty, uom_name)"/>
<small t-esc="uom_name" class="ms-md-1 text-muted"/>
</div>
</td>
<td class="text-end">
<div class="d-flex d-md-block flex-column">
<t t-esc="formatValue(sale_item.qty_delivered, uom_name)"/>
<small t-esc="uom_name" class="ms-md-1 text-muted"/>
</div>
</td>
<td class="text-end">
<div class="d-flex d-md-block flex-column">
<t t-esc="formatValue(sale_item.qty_invoiced, uom_name)"/>
<small t-esc="uom_name" class="ms-md-1 text-muted"/>
</div>
</td>
</tr>
</tbody>
<tfoot>
<tr t-if="state.displayLoadMore">
<td class="pt-0 pb-3" colspan="4">
<a class="btn btn-link" t-on-click="() => this.onLoadMoreClick(revenue)">
Load more <i class="fa fa-arrow-circle-down ms-1"/>
</a>
</td>
</tr>
</tfoot>
</table>
</td>
</tr>
</t>
</t>
</templates>

View file

@ -1,55 +1,9 @@
/** @odoo-module */
import { patch } from '@web/core/utils/patch';
import { formatFloatTime, formatFloat } from "@web/views/fields/formatters";
import { patch } from "@web/core/utils/patch";
import { ProjectRightSidePanel } from '@project/components/project_right_side_panel/project_right_side_panel';
patch(ProjectRightSidePanel.prototype, '@sale_project/components/project_right_side_panel/project_right_side_panel', {
async _loadAdditionalSalesOrderItems() {
const offset = this.state.data.sale_items.data.length;
const totalRecords = this.state.data.sale_items.total;
const limit = totalRecords - offset <= 5 ? totalRecords - offset : 5;
const saleOrderItems = await this.orm.call(
'project.project',
'get_sale_items_data',
[this.projectId, undefined, offset, limit],
{
context: this.context,
},
);
this.state.data.sale_items.data = [...this.state.data.sale_items.data, ...saleOrderItems];
patch(ProjectRightSidePanel.prototype, {
get panelVisible() {
return super.panelVisible || this.state.data.show_sale_items;
},
async onLoadSalesOrderLinesClick() {
const saleItems = this.state.data.sale_items;
if (saleItems && saleItems.total > saleItems.data.length) {
await this._loadAdditionalSalesOrderItems();
}
},
formatValue(value, unit) {
return unit === 'Hours' ? formatFloatTime(value) : formatFloat(value);
},
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {Object} params
*/
async onSaleItemActionClick(params) {
if (params.resId && params.type !== 'object') {
const action = await this.actionService.loadAction(params.name, this.context);
this.actionService.doAction({
...action,
res_id: params.resId,
views: [[false, 'form']]
});
} else {
this.onProjectActionClick(params);
}
},
});

View file

@ -1,56 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="sale_project.ProjectRightSidePanel" t-inherit="project.ProjectRightSidePanel" t-inherit-mode="extension" owl="1">
<xpath expr="//ProjectRightSidePanelSection[@name=&quot;'profitability'&quot;]" position="before">
<ProjectRightSidePanelSection
name="'sales'"
show="state.data.sale_items and state.data.sale_items.total &gt; 0"
>
<t t-set-slot="title" owl="1">
Sales
</t>
<table class="table table-striped table-hover mb-4">
<thead>
<tr>
<th>Sales Order Items</th>
<th class="text-end">Sold</th>
<th class="text-end">Delivered</th>
<th class="text-end">Invoiced</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.data.sale_items.data" t-as="sale_item" t-key="sale_item.id">
<t t-set="uom_name" t-value="sale_item.product_uom and sale_item.product_uom[1]"/>
<td>
<t t-set="sol_name" t-value="sale_item.display_name"/>
<a t-if="sale_item.action" class="o_rightpanel_button" href="#" t-on-click="() => this.onSaleItemActionClick(sale_item.action)">
<t t-esc="sol_name"/>
</a>
<t t-else="" t-esc="sol_name"/>
</td>
<td class="text-end align-middle"><t t-esc="formatValue(sale_item.product_uom_qty, uom_name)"/> <t t-esc="uom_name"/></td>
<td class="text-end align-middle"><t t-esc="formatValue(sale_item.qty_delivered, uom_name)"/> <t t-esc="uom_name"/></td>
<td class="text-end align-middle"><t t-esc="formatValue(sale_item.qty_invoiced, uom_name)"/> <t t-esc="uom_name"/></td>
</tr>
</tbody>
<tfoot>
<tr class="o_rightpanel_nohover border-0" t-if="state.data.sale_items.total &gt; state.data.sale_items.data.length">
<td class="pb-0 border-0 text-center" colspan="4">
<a class="btn btn-link" t-on-click="onLoadSalesOrderLinesClick">
Load more
</a>
</td>
</tr>
<tr t-else="">
<td class="pb-0 border-0 shadow-none text-center" colspan="4">
<small class="text-muted">All items have been loaded</small>
</td>
</tr>
</tfoot>
</table>
</ProjectRightSidePanelSection>
<t t-name="sale_project.ProjectRightSidePanel" t-inherit="project.ProjectRightSidePanel" t-inherit-mode="extension">
<xpath expr="//ProjectProfitability" position="replace">
<ProjectProfitability
t-if="showProjectProfitability"
data="state.data.profitability_items"
labels="state.data.profitability_labels"
formatMonetary="formatMonetary.bind(this)"
onProjectActionClick="onProjectActionClick.bind(this)"
onClick="(params) => this.onProjectActionClick(params)"
projectId="projectId"
context="context"
/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,49 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { user } from "@web/core/user";
import { Component } from "@odoo/owl";
export class SoLineCreateButton extends Component {
static template = "sale_timesheet.SoLineCreateButton";
static components = { Many2One };
static props = { ...Many2OneField.props };
setup() {
super.setup();
this.dialogService = useService("dialog");
}
openSalesOrderDialog() {
const { context, record } = this.props;
this.dialogService.add(FormViewDialog, {
title: "Create Sales Order",
resModel: "sale.order",
context: {
...context,
form_view_ref: context.so_form_view_ref,
default_company_id: context.default_company_id || user.activeCompany.id,
default_user_id: user.userId,
hide_pdf_quote_builder: true,
},
onRecordSaved: async (rec) => {
const service_line_id = await rec.model.orm.call(
"sale.order",
"get_first_service_line",
[rec.resId]
);
record.update({ sale_line_id: { id: service_line_id[0] } });
},
});
}
get m2oProps() {
return computeM2OProps(this.props);
}
}
registry.category("fields").add("so_line_create_button", {
...buildM2OFieldDescription(SoLineCreateButton),
});

View file

@ -0,0 +1,8 @@
body:not(.o_touch_device) .o_field_so_line_create_button {
&:not(:hover):not(:focus-within) {
& .o_many2one:not(:hover) ~ a {
display: none !important;
}
}
word-break: keep-all !important;
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale_timesheet.SoLineCreateButton">
<div class="d-flex align-items-center gap-1">
<Many2One t-props="m2oProps" cssClass="'w-100'"/>
<a
t-if="!props.readonly and !props.record.data.sale_line_id"
type="button"
class="ms-3 d-inline-flex align-items-center"
tabindex="-1"
draggable="false"
aria-label="Create Sales Order"
data-tooltip="Create Sales Order"
t-on-click="openSalesOrderDialog"
>
<i class="fa fa-plus"/>
</a>
</div>
</t>
</templates>

View file

@ -0,0 +1,16 @@
.o_bottom_sheet .o_bottom_sheet_sheet {
.o-dropdown-item {
.oe_stat_button[name="action_view_project_ids"] {
> .o_stat_info {
flex-direction: column;
> .o_row {
display: flex;
gap: inherit;
padding-block: map-get($spacers, 1);
align-items: baseline;
}
}
}
}
}

View file

@ -0,0 +1,12 @@
import { patch } from "@web/core/utils/patch";
import { ProjectTaskListRenderer } from "@project/views/project_task_list/project_task_list_renderer";
patch(ProjectTaskListRenderer.prototype, {
isCellReadonly(column, record) {
let readonly = false;
if (column.name === "sale_line_id") {
readonly = !this.haveAllSelectedTasksSameField('partner_id');
}
return readonly || super.isCellReadonly(column, record);
}
});