19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:07 +01:00
parent ba20ce7443
commit 768b70e05e
2357 changed files with 1057103 additions and 712486 deletions

View file

@ -0,0 +1,21 @@
import {Component} from "@odoo/owl";
import {registry} from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class AccountBatchSendingSummary extends Component {
static template = "account.BatchSendingSummary";
static props = {
...standardFieldProps,
};
setup() {
super.setup();
this.data = this.props.record.data[this.props.name];
}
}
export const accountBatchSendingSummary = {
component: AccountBatchSendingSummary,
}
registry.category("fields").add("account_batch_sending_summary", accountBatchSendingSummary);

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template>
<t t-name="account.BatchSendingSummary">
<p>You are about to send</p>
<ul>
<li t-foreach="this.data" t-as="summary_entry" t-key="summary_entry">
<t t-out="summary_entry_value.count"/> invoice(s)
<t t-out="summary_entry_value.label"/>
<t t-if="summary_entry_value.extra" t-out="summary_entry_value.extra"/>
</li>
</ul>
</t>
</template>

View file

@ -0,0 +1,48 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { DocumentFileUploader } from "../document_file_uploader/document_file_uploader";
export class AccountFileUploader extends DocumentFileUploader {
static template = "account.AccountFileUploader";
static props = {
...DocumentFileUploader.props,
btnClass: { type: String, optional: true },
linkText: { type: String, optional: true },
togglerTemplate: { type: String, optional: true },
};
getExtraContext() {
const extraContext = super.getExtraContext();
const record_data = this.props.record ? this.props.record.data : false;
return record_data ? {
...extraContext,
default_journal_id: record_data.id,
default_move_type: (
(record_data.type === 'sale' && 'out_invoice')
|| (record_data.type === 'purchase' && 'in_invoice')
|| 'entry'
),
} : extraContext;
}
getResModel() {
return "account.journal";
}
}
//when file uploader is used on account.journal (with a record)
export const accountFileUploader = {
component: AccountFileUploader,
extractProps: ({ attrs }) => ({
togglerTemplate: attrs.template || "account.JournalUploadLink",
btnClass: attrs.btnClass || "",
linkText: attrs.title || _t("Upload"),
}),
fieldDependencies: [
{ name: "id", type: "integer" },
{ name: "type", type: "selection" },
],
};
registry.category("view_widgets").add("account_file_uploader", accountFileUploader);

View file

@ -0,0 +1,7 @@
.o_widget_account_file_uploader {
button.oe_kanban_action {
a {
color: var(--btn-color);
}
}
}

View file

@ -0,0 +1,26 @@
<templates>
<t t-name="account.AccountFileUploader" t-inherit="account.DocumentFileUploader" t-inherit-mode="primary">
<xpath expr="//t[@t-slot='toggler']" position="replace">
<t t-if="props.togglerTemplate" t-call="{{ props.togglerTemplate }}"/>
<t t-else="" t-slot="toggler"/>
</xpath>
</t>
<t t-name="account.AccountViewUploadButton">
<AccountFileUploader>
<t t-set-slot="toggler">
<button type="button" class="btn btn-secondary o_button_upload_bill" data-hotkey="shift+i">
Upload
</button>
</t>
</AccountFileUploader>
</t>
<t t-name="account.JournalUploadLink">
<t groups="account.group_account_invoice">
<a t-att-class="props.btnClass" href="#" t-out="props.linkText" draggable="false"/>
</t>
</t>
</templates>

View file

@ -0,0 +1,61 @@
import { registry } from "@web/core/registry";
import {
SectionAndNoteListRenderer,
SectionAndNoteFieldOne2Many,
sectionAndNoteFieldOne2Many,
} from "../section_and_note_fields_backend/section_and_note_fields_backend";
export class AccountMergeWizardLinesRenderer extends SectionAndNoteListRenderer {
setup() {
super.setup();
this.titleField = "info";
}
getCellClass(column, record) {
const classNames = super.getCellClass(column, record);
// Even though the `is_selected` field is invisible for section lines, we should
// keep its column (which would be hidden by the call to super.getCellClass)
// in order to align the section header name with the account names.
if (this.isSectionOrNote(record) && column.name === "is_selected") {
return classNames.replace(" o_hidden", "");
}
return classNames;
}
/** @override **/
getSectionColumns(columns) {
const sectionCols = columns.filter(
(col) =>
col.type === "field" && (col.name === this.titleField || col.name === "is_selected")
);
return sectionCols.map((col) => {
if (col.name === this.titleField) {
return { ...col, colspan: columns.length - sectionCols.length + 1 };
} else {
return { ...col };
}
});
}
/** @override */
isSortable(column) {
// Don't allow sorting columns, as that doesn't make sense in the wizard view.
return false;
}
}
export class AccountMergeWizardLinesOne2Many extends SectionAndNoteFieldOne2Many {
static components = {
...SectionAndNoteFieldOne2Many.components,
ListRenderer: AccountMergeWizardLinesRenderer,
};
}
export const accountMergeWizardLinesOne2Many = {
...sectionAndNoteFieldOne2Many,
component: AccountMergeWizardLinesOne2Many,
};
registry
.category("fields")
.add("account_merge_wizard_lines_one2many", accountMergeWizardLinesOne2Many);

View file

@ -1,5 +1,3 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { createElement, append } from "@web/core/utils/xml";
import { Notebook } from "@web/core/notebook/notebook";
@ -8,27 +6,43 @@ import { FormCompiler } from "@web/views/form/form_compiler";
import { FormRenderer } from "@web/views/form/form_renderer";
import { FormController } from '@web/views/form/form_controller';
import { useService } from "@web/core/utils/hooks";
import { deleteConfirmationMessage } from "@web/core/confirmation_dialog/confirmation_dialog";
import {_t} from "@web/core/l10n/translation";
export class AccountMoveController extends FormController {
export class AccountMoveFormController extends FormController {
setup() {
super.setup();
this.account_move_service = useService("account_move");
}
async deleteRecord() {
if ( !await this.account_move_service.addDeletionDialog(this, this.model.root.resId)) {
return super.deleteRecord(...arguments);
}
get cogMenuProps() {
return {
...super.cogMenuProps,
printDropdownTitle: _t("Print"),
loadExtraPrintItems: this.loadExtraPrintItems.bind(this),
};
}
};
async loadExtraPrintItems() {
const items = await this.orm.call("account.move", "get_extra_print_items", [this.model.root.resId]);
return items.filter((item) => item.key !== "download_all");
}
async deleteRecord() {
const deleteConfirmationDialogProps = this.deleteConfirmationDialogProps;
deleteConfirmationDialogProps.body = await this.account_move_service.getDeletionDialogBody(deleteConfirmationMessage, this.model.root.resId);
this.deleteRecordsWithConfirmation(deleteConfirmationDialogProps, [this.model.root]);
}
}
export class AccountMoveFormNotebook extends Notebook {
onAnchorClicked(ev) {
if (ev.detail.detail.id === "#outstanding") {
ev.preventDefault();
ev.detail.detail.originalEv.preventDefault();
}
}
static template = "account.AccountMoveFormNotebook";
static props = {
...Notebook.props,
onBeforeTabSwitch: { type: Function, optional: true },
};
async changeTabTo(page_id) {
if (this.props.onBeforeTabSwitch) {
@ -37,29 +51,25 @@ export class AccountMoveFormNotebook extends Notebook {
this.state.currentPage = page_id;
}
}
AccountMoveFormNotebook.template = "account.AccountMoveFormNotebook";
AccountMoveFormNotebook.props = {
...Notebook.props,
onBeforeTabSwitch: { type: Function, optional: true },
}
export class AccountMoveFormRenderer extends FormRenderer {
static components = {
...FormRenderer.components,
AccountMoveFormNotebook: AccountMoveFormNotebook,
};
async saveBeforeTabChange() {
if (this.props.record.mode === "edit" && this.props.record.isDirty) {
if (this.props.record.isInEdition && await this.props.record.isDirty()) {
const contentEl = document.querySelector('.o_content');
const scrollPos = contentEl.scrollTop;
await this.props.record.save({
stayInEdition: true,
});
await this.props.record.save();
if (scrollPos) {
contentEl.scrollTop = scrollPos;
}
}
}
}
AccountMoveFormRenderer.components = {
...FormRenderer.components,
AccountMoveFormNotebook: AccountMoveFormNotebook,
}
export class AccountMoveFormCompiler extends FormCompiler {
compileNotebook(el, params) {
const originalNoteBook = super.compileNotebook(...arguments);
@ -67,7 +77,7 @@ export class AccountMoveFormCompiler extends FormCompiler {
for (const attr of originalNoteBook.attributes) {
noteBook.setAttribute(attr.name, attr.value);
}
noteBook.setAttribute("onBeforeTabSwitch", "() => this.saveBeforeTabChange()");
noteBook.setAttribute("onBeforeTabSwitch", "() => __comp__.saveBeforeTabChange()");
const slots = originalNoteBook.childNodes;
append(noteBook, [...slots]);
return noteBook;
@ -78,7 +88,7 @@ export const AccountMoveFormView = {
...formView,
Renderer: AccountMoveFormRenderer,
Compiler: AccountMoveFormCompiler,
Controller: AccountMoveController,
Controller: AccountMoveFormController,
};
registry.category("views").add("account_move_form", AccountMoveFormView);

View file

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.AccountMoveFormNotebook" t-inherit="web.Notebook" t-inherit-mode="primary" owl="1">
<t t-name="account.AccountMoveFormNotebook" t-inherit="web.Notebook" t-inherit-mode="primary">
<xpath expr="//a[@class='nav-link']" position="attributes">
<attribute name="t-on-click.prevent">() => this.changeTabTo(navItem[0])</attribute>
<attribute name="tabindex">-1</attribute>
</xpath>
</t>
</templates>

View file

@ -1,28 +0,0 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { escape } from "@web/core/utils/strings";
import { markup } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const accountMove = {
dependencies: ["dialog", "orm"],
start(env, { dialog, orm }) {
return {
async addDeletionDialog(component, moveIds) {
const isMoveEndOfChain = await orm.call('account.move', 'check_move_sequence_chain', [moveIds]);
if (!isMoveEndOfChain) {
const message = env._t("This operation will create a gap in the sequence.");
const confirmationDialogProps = component.deleteConfirmationDialogProps;
confirmationDialogProps.body = markup(`<div class="text-danger">${escape(message)}</div>${escape(confirmationDialogProps.body)}`);
dialog.add(ConfirmationDialog, confirmationDialogProps);
return true;
}
return false;
}
}
}
}
registry.category("services").add("account_move", accountMove);

View file

@ -2,95 +2,112 @@
<templates xml:space="preserve">
<t t-name="account.AccountPaymentField" owl="1">
<t t-name="account.AccountPaymentField">
<div>
<t t-if="outstanding">
<div>
<strong class="float-start" id="outstanding" t-esc="title"/>
</div>
</t>
<table style="width:100%;">
<t t-foreach="lines" t-as="line" t-key="line_index">
<tr>
<t t-if="outstanding">
<td>
<a title="assign to invoice"
role="button"
class="oe_form_field btn btn-secondary outstanding_credit_assign"
t-att-data-id="line.id"
style="margin-right: 0px; padding-left: 5px; padding-right: 5px;"
href="#"
data-bs-toggle="tooltip"
t-on-click.prevent="() => this.assignOutstandingCredit(line.id)">Add</a>
</td>
<td style="max-width: 11em;">
<a t-att-title="line.date"
role="button"
class="oe_form_field btn btn-link open_account_move"
t-on-click="() => this.openMove(line.move_id)"
style="margin-right: 5px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; padding-left: 0px; width:100%; text-align:left;"
data-bs-toggle="tooltip"
t-att-payment-id="account_payment_id"
t-esc="line.journal_name"/>
</td>
<t t-set="info" t-value="this.getInfo()"/>
<div class="d-flex flex-column align-items-end">
<table class="w-auto">
<t t-if="info.outstanding">
<tr>
<td colspan="2" class="text-start">
<strong id="outstanding" t-out="info.title"/>
</td>
</tr>
</t>
<t t-if="!outstanding">
<td>
<a role="button" tabindex="0" class="js_payment_info fa fa-info-circle" t-att-index="line_index" style="margin-right:5px;" aria-label="Info" title="Journal Entry Info" data-bs-toggle="tooltip" t-on-click.stop="(ev) => this.onInfoClick(ev, line_index)"></a>
</td>
<td t-if="!line.is_exchange">
<i class="o_field_widget text-start o_payment_label">Paid on <t t-esc="line.date"></t></i>
</td>
<td t-if="line.is_exchange" colspan="2">
<i class="o_field_widget text-start text-muted text-start">
<span class="oe_form_field oe_form_field_float oe_form_field_monetary fw-bold">
<t t-esc="line.amount_formatted"/>
</span>
<span> Exchange Difference</span>
</i>
<t t-foreach="info.lines" t-as="line" t-key="line_index">
<tr>
<t t-if="info.outstanding">
<td t-out="line.formattedDate"/>
<td style="max-width: 9rem;">
<a t-att-title="(line.bank_label ? line.bank_label + ' - ' : '') + (line.move_ref ? line.move_ref : '')"
role="button"
class="open_account_move oe_form_field btn btn-link w-100 text-start"
t-on-click="() => this.openMove(line.move_id)"
data-bs-toggle="tooltip"
t-att-payment-id="account_payment_id"
t-out="line.journal_name"/>
</td>
<td class="ps-2">
<a title="assign to invoice"
role="button"
class="oe_form_field btn btn-secondary outstanding_credit_assign d-print-none text-truncate w-100 text-start"
t-att-data-id="line.id"
href="#"
data-bs-toggle="tooltip"
t-on-click.prevent="() => this.assignOutstandingCredit(info.moveId, line.id)">Add</a>
</td>
</t>
<t t-if="!info.outstanding">
<td>
<a role="button" tabindex="0" class="js_payment_info fa fa-info-circle" t-att-index="line_index" style="margin-right:5px;" aria-label="Info" title="Journal Entry Info" data-bs-toggle="tooltip" t-on-click.stop="(ev) => this.onInfoClick(ev, line)"></a>
</td>
<td t-if="!line.is_exchange">
<i class="o_field_widget text-start o_payment_label">
<t t-if="line.is_refund">Reversed on </t>
<t t-else="">Paid on </t>
<t t-out="line.formattedDate"></t>
</i>
</td>
<td t-if="line.is_exchange" colspan="2">
<i class="o_field_widget text-start text-muted text-start">
<span class="oe_form_field oe_form_field_float oe_form_field_monetary fw-bold">
<t t-out="line.amount_formatted"/>
</span>
<span> Exchange Difference</span>
</i>
</td>
</t>
<td t-if="!line.is_exchange" class="text-end ps-2 text-nowrap">
<span class="oe_form_field oe_form_field_float oe_form_field_monetary">
<t t-out="line.amount_formatted"/>
</span>
</td>
</tr>
</t>
<td t-if="!line.is_exchange" style="text-align:right;">
<span class="oe_form_field oe_form_field_float oe_form_field_monetary" style="margin-left: -10px;">
<t t-esc="line.amount_formatted"/>
</span>
</td>
</tr>
</t>
</table>
</table>
</div>
</div>
</t>
<t t-name="account.AccountPaymentPopOver" owl="1">
<t t-name="account.AccountPaymentPopOver">
<div class="account_payment_popover">
<h3 t-if="props.title" class="o_popover_header"><t t-esc="props.title"/></h3>
<h3 t-if="props.title" class="popover-header"><t t-out="props.title"/></h3>
<div class="px-2">
<div>
<table>
<tr>
<td><strong>Amount: </strong></td>
<td>
<t t-esc="props.amount_company_currency"></t>
<td class="fw-bolder">Amount:</td>
<td class="ps-1">
<t t-out="props.amount_company_currency"></t>
<t t-if="props.amount_foreign_currency">
(<span class="fa fa-money"/> <t t-esc="props.amount_foreign_currency"/>)
(<span class="fa fa-money"/> <t t-out="props.amount_foreign_currency"/>)
</t>
</td>
</tr>
<tr>
<td><strong>Memo: </strong></td>
<td>
<div style="width: 200px; word-wrap: break-word">
<t t-esc="props.ref"/>
<td class="fw-bolder align-top">Memo:</td>
<td class="ps-1">
<div class="o_memo_content" t-att-data-tooltip="props.ref" data-tooltip-position="left">
<t t-out="props.ref"/>
</div>
</td>
</tr>
<tr>
<td><strong>Date: </strong></td>
<td><t t-esc="props.date"/></td>
<td class="fw-bolder">Date:</td>
<td class="ps-1"><t t-out="props.date"/></td>
</tr>
<tr>
<td><strong>Journal: </strong></td>
<td><t t-esc="props.journal_name"/><span t-if="props.payment_method_name"> (<t t-esc="props.payment_method_name"/>)</span></td>
<td class="fw-bolder">Journal:</td>
<td class="ps-1">
<t t-out="props.journal_name"/>
<span t-if="props.payment_method_name">
(<t t-out="props.payment_method_name"/>)
</span>
</td>
</tr>
<tr t-if="props.company_name">
<td class="fw-bolder">Branch:</td>
<td class="ps-1"><t t-out="props.company_name"/></td>
</tr>
</table>
</div>

View file

@ -1,85 +1,73 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { localization } from "@web/core/l10n/localization";
import { parseDate, formatDate } from "@web/core/l10n/dates";
import { formatDate, deserializeDate } from "@web/core/l10n/dates";
import { formatMonetary } from "@web/views/fields/formatters";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Component } from "@odoo/owl";
const { Component, onWillUpdateProps } = owl;
class AccountPaymentPopOver extends Component {}
AccountPaymentPopOver.template = "account.AccountPaymentPopOver";
class AccountPaymentPopOver extends Component {
static props = { "*": { optional: true } };
static template = "account.AccountPaymentPopOver";
}
export class AccountPaymentField extends Component {
static props = { ...standardFieldProps };
static template = "account.AccountPaymentField";
setup() {
this.popover = usePopover();
const position = localization.direction === "rtl" ? "bottom" : "left";
this.popover = usePopover(AccountPaymentPopOver, { position });
this.orm = useService("orm");
this.action = useService("action");
this.formatData(this.props);
onWillUpdateProps((nextProps) => this.formatData(nextProps));
}
formatData(props) {
const info = props.value || {
getInfo() {
const info = this.props.record.data[this.props.name] || {
content: [],
outstanding: false,
title: "",
move_id: this.props.record.data.id,
move_id: this.props.record.resId,
};
for (let [key, value] of Object.entries(info.content)) {
for (const [key, value] of Object.entries(info.content)) {
value.index = key;
value.amount_formatted = formatMonetary(value.amount, { currencyId: value.currency_id });
value.amount_formatted = formatMonetary(value.amount, {
currencyId: value.currency_id,
});
if (value.date) {
// value.date is a string, parse to date and format to the users date format
value.date = formatDate(parseDate(value.date));
value.formattedDate = formatDate(deserializeDate(value.date))
}
}
this.lines = info.content;
this.outstanding = info.outstanding;
this.title = info.title;
this.move_id = info.move_id;
return {
lines: info.content,
outstanding: info.outstanding,
title: info.title,
moveId: info.move_id,
};
}
onInfoClick(ev, idx) {
if (this.popoverCloseFn) {
this.closePopover();
}
this.popoverCloseFn = this.popover.add(
ev.currentTarget,
AccountPaymentPopOver,
{
title: this.env._t("Journal Entry Info"),
...this.lines[idx],
_onRemoveMoveReconcile: this.removeMoveReconcile.bind(this),
_onOpenMove: this.openMove.bind(this),
onClose: this.closePopover,
},
{
position: localization.direction === "rtl" ? "bottom" : "left",
},
);
onInfoClick(ev, line) {
this.popover.open(ev.currentTarget, {
title: _t("Journal Entry Info"),
...line,
_onRemoveMoveReconcile: this.removeMoveReconcile.bind(this),
_onOpenMove: this.openMove.bind(this),
});
}
closePopover() {
this.popoverCloseFn();
this.popoverCloseFn = null;
}
async assignOutstandingCredit(id) {
await this.orm.call(this.props.record.resModel, 'js_assign_outstanding_line', [this.move_id, id], {});
async assignOutstandingCredit(moveId, id) {
await this.orm.call(this.props.record.resModel, 'js_assign_outstanding_line', [moveId, id], {});
await this.props.record.model.root.load();
this.props.record.model.notify();
}
async removeMoveReconcile(moveId, partialId) {
this.closePopover();
this.popover.close();
await this.orm.call(this.props.record.resModel, 'js_remove_outstanding_partial', [moveId, partialId], {});
await this.props.record.model.root.load();
this.props.record.model.notify();
}
async openMove(moveId) {
@ -87,7 +75,10 @@ export class AccountPaymentField extends Component {
this.action.doAction(action);
}
}
AccountPaymentField.template = "account.AccountPaymentField";
AccountPaymentField.supportedTypes = ["char"];
registry.category("fields").add("payment", AccountPaymentField);
export const accountPaymentField = {
component: AccountPaymentField,
supportedTypes: ["binary"],
};
registry.category("fields").add("payment", accountPaymentField);

View file

@ -0,0 +1,23 @@
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class AccountPaymentRegisterHtmlField extends Component {
static props = standardFieldProps;
static template = "account.AccountPaymentRegisterHtmlField";
get value() {
return this.props.record.data[this.props.name];
}
switchInstallmentsAmount(ev) {
if (ev.srcElement.classList.contains("installments_switch_button")) {
const root = this.env.model.root;
root.update({ amount: root.data.installments_switch_amount });
}
}
}
const accountPaymentRegisterHtmlField = { component: AccountPaymentRegisterHtmlField };
registry.category("fields").add("account_payment_register_html", accountPaymentRegisterHtmlField);

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.AccountPaymentRegisterHtmlField">
<div t-out="value" t-on-click="switchInstallmentsAmount"/>
</t>
</templates>

View file

@ -0,0 +1,25 @@
import { registry } from "@web/core/registry";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { useAddInlineRecord } from "@web/views/fields/relational_utils";
export class PaymentTermLineIdsOne2Many extends X2ManyField {
setup() {
super.setup();
// Overloads the addInLine method to mark all new records as 'dirty' by calling update with an empty object.
// This prevents the records from being abandoned if the user clicks globally or on an existing record.
this.addInLine = useAddInlineRecord({
addNew: async (...args) => {
const newRecord = await this.list.addNewRecord(...args);
newRecord.update({});
}
});
}
}
export const PaymentTermLineIds = {
...x2ManyField,
component: PaymentTermLineIdsOne2Many,
}
registry.category("fields").add("payment_term_line_ids", PaymentTermLineIds);

View file

@ -0,0 +1,44 @@
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
import { useService } from "@web/core/utils/hooks";
import { today } from "@web/core/l10n/dates";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
export class AccountPickCurrencyDate extends Component {
static template = "account.AccountPickCurrencyDate";
static props = {
...standardWidgetProps,
record: { type: Object, optional: true },
};
setup() {
this.orm = useService("orm");
this.dateTimePicker = useDateTimePicker({
target: 'datetime-picker-target',
onApply: async (date) => {
const record = this.props.record
const rate = await this.orm.call(
'account.move',
'get_currency_rate',
[record.resId, record.data.company_id.id, record.data.currency_id.id, date.toISODate()],
);
this.props.record.update({ invoice_currency_rate: rate });
await this.props.record.save();
},
get pickerProps() {
return {
type: 'date',
value: today(),
};
},
});
}
}
export const accountPickCurrencyDate = {
component: AccountPickCurrencyDate,
}
registry.category("view_widgets").add("account_pick_currency_date", accountPickCurrencyDate);

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template>
<t t-name="account.AccountPickCurrencyDate">
<button
type="button"
t-on-click.prevent="() => this.dateTimePicker.open()"
class="btn btn-link text-dark p-0"
title="Pick the rate on a certain date"
t-ref="datetime-picker-target"
>
<i class="fa fa-calendar"/>
</button>
</t>
</template>

View file

@ -1,27 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.ResequenceRenderer" owl="1" >
<table t-if="data.changeLines.length" class="table table-sm">
<t t-name="account.ResequenceRenderer" >
<t t-set="value" t-value="this.getValue()"/>
<table t-if="value.changeLines.length" class="table table-sm">
<thead><tr>
<th>Date</th>
<th>Before</th>
<th>After</th>
</tr></thead>
<tbody>
<t t-foreach="data.changeLines" t-as="changeLine" t-key="changeLine.id">
<ChangeLine changeLine="changeLine" ordering="data.ordering"/>
<t t-foreach="value.changeLines" t-as="changeLine" t-key="changeLine.id">
<ChangeLine changeLine="changeLine" ordering="value.ordering"/>
</t>
</tbody>
</table>
</t>
<t t-name="account.ResequenceChangeLine" owl="1">
<t t-name="account.ResequenceChangeLine">
<tr>
<td t-esc="props.changeLine.date"/>
<td t-esc="props.changeLine.current_name"/>
<td t-if="props.ordering == 'keep'" t-esc="props.changeLine.new_by_name" t-attf-class="{{ props.changeLine.new_by_name != props.changeLine.new_by_date ? 'animate' : ''}}"/>
<td t-else="" t-esc="props.changeLine.new_by_date" t-attf-class="{{ props.changeLine.new_by_name != props.changeLine.new_by_date ? 'animate' : ''}}"/>
<td t-out="props.changeLine.date"/>
<td t-out="props.changeLine.current_name"/>
<td t-if="props.ordering == 'keep'" t-out="props.changeLine.new_by_name" t-attf-class="{{ props.changeLine.new_by_name != props.changeLine.new_by_date ? 'animate' : ''}}"/>
<td t-else="" t-out="props.changeLine.new_by_date" t-attf-class="{{ props.changeLine.new_by_name != props.changeLine.new_by_date ? 'animate' : ''}}"/>
</tr>
</t>
</templates>

View file

@ -1,24 +1,22 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
const { Component, onWillUpdateProps } = owl;
class ChangeLine extends Component {}
ChangeLine.template = "account.ResequenceChangeLine";
ChangeLine.props = ["changeLine", "ordering"];
class ChangeLine extends Component {
static template = "account.ResequenceChangeLine";
static props = ["changeLine", "ordering"];
}
class ShowResequenceRenderer extends Component {
setup() {
this.formatData(this.props);
onWillUpdateProps((nextProps) => this.formatData(nextProps));
}
formatData(props) {
this.data = props.value ? JSON.parse(props.value) : { changeLines: [], ordering: "date" };
static template = "account.ResequenceRenderer";
static components = { ChangeLine };
static props = { ...standardFieldProps };
getValue() {
const value = this.props.record.data[this.props.name];
return value ? JSON.parse(value) : { changeLines: [], ordering: "date" };
}
}
ShowResequenceRenderer.template = "account.ResequenceRenderer";
ShowResequenceRenderer.components = { ChangeLine };
registry.category("fields").add("account_resequence_widget", ShowResequenceRenderer);
registry.category("fields").add("account_resequence_widget", {
component: ShowResequenceRenderer,
});

View file

@ -0,0 +1,25 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { statusBarField, StatusBarField } from "@web/views/fields/statusbar/statusbar_field";
export class AccountMoveStatusBarSecuredField extends StatusBarField {
static template = "account.MoveStatusBarSecuredField";
get isSecured() {
return this.props.record.data['secured'];
}
get currentItem() {
return this.getAllItems().find((item) => item.isSelected);
}
}
export const accountMoveStatusBarSecuredField = {
...statusBarField,
component: AccountMoveStatusBarSecuredField,
displayName: _t("Status with secured indicator for Journal Entries"),
supportedTypes: ["selection"],
additionalClasses: ["o_field_statusbar"],
};
registry.category("fields").add("account_move_statusbar_secured", accountMoveStatusBarSecuredField);

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Add "secured" indicator to the posted state -->
<t t-name="account.MoveStatusBarSecuredField.ItemLabel">
<span t-esc="item.label" />
<t t-if="item.value == 'posted'">
<i t-attf-class="fa fa-fw ms-1 #{isSecured ? 'fa-lock text-success' : 'fa-unlock text-warning'}"/>
</t>
</t>
<t t-name="account.MoveStatusBarSecuredField.Dropdown" t-inherit="web.StatusBarField.Dropdown" t-inherit-mode="primary">
<xpath expr="//span[@t-esc='item.label']" position="replace">
<t t-call="account.MoveStatusBarSecuredField.ItemLabel"/>
</xpath>
</t>
<t t-name="account.MoveStatusBarSecuredField" t-inherit="web.StatusBarField" t-inherit-mode="primary">
<xpath expr="//*[@t-call='web.StatusBarField.Dropdown']" position="attributes">
<attribute name="t-call">account.MoveStatusBarSecuredField.Dropdown</attribute>
</xpath>
<xpath expr="//*[@t-esc='item.label']" position="inside">
<t t-call="account.MoveStatusBarSecuredField.ItemLabel"/>
</xpath>
<xpath expr="//*[@t-esc='item.label']" position="attributes">
<attribute name="t-esc" />
</xpath>
<xpath expr="//*[@t-out='getCurrentLabel()']" position="replace">
<t t-set="item" t-value="currentItem"/>
<t t-call="account.MoveStatusBarSecuredField.ItemLabel"/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,54 @@
import { FloatField, floatField } from "@web/views/fields/float/float_field";
import { roundPrecision } from "@web/core/utils/numbers";
import {registry} from "@web/core/registry";
export class AccountTaxRepartitionLineFactorPercent extends FloatField {
static defaultProps = {
...FloatField.defaultProps,
digits: [16, 12],
};
/*
* @override
* We don't want to display all amounts with 12 digits behind so we remove the trailing 0
* as much as possible.
*/
get formattedValue() {
const value = super.formattedValue;
const trailingNumbersMatch = value.match(/(\d+)$/);
if (!trailingNumbersMatch) {
return value;
}
const trailingZeroMatch = trailingNumbersMatch[1].match(/(0+)$/);
if (!trailingZeroMatch) {
return value;
}
const nbTrailingZeroToRemove = Math.min(trailingZeroMatch[1].length, trailingNumbersMatch[1].length - 2);
return value.substring(0, value.length - nbTrailingZeroToRemove);
}
/*
* @override
* Prevent the users of showing a rounding at 12 digits on the screen but
* getting an unrounded value after typing "= 2/3" on the field when saving.
*/
parse(value) {
const parsedValue = super.parse(value);
try {
Number(parsedValue);
} catch {
return parsedValue;
}
const precisionRounding = Number(`1e-${this.props.digits[1]}`);
return roundPrecision(parsedValue, precisionRounding);
}
}
export const accountTaxRepartitionLineFactorPercent = {
...floatField,
component: AccountTaxRepartitionLineFactorPercent,
};
registry.category("fields").add("account_tax_repartition_line_factor_percent", accountTaxRepartitionLineFactorPercent);

View file

@ -1,23 +1,62 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { SelectionField } from "@web/views/fields/selection/selection_field";
import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field";
export class AccountTypeSelection extends SelectionField {
get hierarchyOptions() {
const opts = this.options;
return [
{ name: this.env._t('Balance Sheet') },
{ name: this.env._t('Assets'), children: opts.filter(x => x[0] && x[0].startsWith('asset')) },
{ name: this.env._t('Liabilities'), children: opts.filter(x => x[0] && x[0].startsWith('liability')) },
{ name: this.env._t('Equity'), children: opts.filter(x => x[0] && x[0].startsWith('equity')) },
{ name: this.env._t('Profit & Loss') },
{ name: this.env._t('Income'), children: opts.filter(x => x[0] && x[0].startsWith('income')) },
{ name: this.env._t('Expense'), children: opts.filter(x => x[0] && x[0].startsWith('expense')) },
{ name: this.env._t('Other'), children: opts.filter(x => x[0] && x[0] === 'off_balance') },
static template = "account.AccountTypeSelection";
setup() {
super.setup();
const getChoicesForGroup = (group) => {
return this.choices.filter(x => x.value.startsWith(group));
}
this.sections = [
{
label: _t('Balance Sheet'),
name: "balance_sheet"
},
{
label: _t('Profit & Loss'),
name: "profit_and_loss"
},
]
this.groups = [
{
label: _t('Assets'),
choices: getChoicesForGroup('asset'),
section: "balance_sheet",
},
{
label: _t('Liabilities'),
choices: getChoicesForGroup('liability'),
section: "balance_sheet",
},
{
label: _t('Equity'),
choices: getChoicesForGroup('equity'),
section: "balance_sheet",
},
{
label: _t('Income'),
choices: getChoicesForGroup('income'),
section: "profit_and_loss",
},
{
label: _t('Expense'),
choices: getChoicesForGroup('expense'),
section: "profit_and_loss",
},
{
label: _t('Other'),
choices: getChoicesForGroup('off_balance'),
section: "profit_and_loss",
},
];
}
}
AccountTypeSelection.template = "account.AccountTypeSelection";
registry.category("fields").add("account_type_selection", AccountTypeSelection);
export const accountTypeSelection = {
...selectionField,
component: AccountTypeSelection,
};
registry.category("fields").add("account_type_selection", accountTypeSelection);

View file

@ -2,20 +2,11 @@
<templates>
<t t-name="account.AccountTypeSelection" t-inherit="web.SelectionField" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-foreach='options']" position="replace">
<t t-foreach="hierarchyOptions" t-as="group" t-key="group_index">
<optgroup t-att-label="group.name">
<t t-if="!!group.children">
<t t-foreach="group.children" t-as="child" t-key="child[0]">
<option
t-att-selected="child[0] === value"
t-att-value="stringify(child[0])"
t-esc="child[1]"/>
</t>
</t>
</optgroup>
</t>
<t t-name="account.AccountTypeSelection" t-inherit="web.SelectionField" t-inherit-mode="primary">
<xpath expr="//SelectMenu" position="attributes">
<attribute name="choices"></attribute>
<attribute name="groups">groups</attribute>
<attribute name="sections">sections</attribute>
</xpath>
</t>

View file

@ -0,0 +1,57 @@
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { useService } from "@web/core/utils/hooks";
const WARNING_TYPE_ORDER = ["danger", "warning", "info"];
export class ActionableErrors extends Component {
static props = { errorData: {type: Object} };
static template = "account.ActionableErrors";
setup() {
super.setup();
this.actionService = useService("action");
this.orm = useService("orm");
}
get errorData() {
return this.props.errorData;
}
async handleOnClick(errorData){
if (errorData.action?.view_mode) {
// view_mode is not handled JS side
errorData.action['views'] = errorData.action.view_mode.split(',').map(mode => [false, mode]);
delete errorData.action['view_mode'];
}
if (errorData.action_call) {
const [model, method, args] = errorData.action_call;
await this.orm.call(model, method, [args]);
this.env.model.action.doAction("soft_reload");
} else {
this.env.model.action.doAction(errorData.action);
}
}
get sortedActionableErrors() {
return this.errorData && Object.fromEntries(
Object.entries(this.errorData).sort(
(a, b) =>
WARNING_TYPE_ORDER.indexOf(a[1]["level"] || "warning") -
WARNING_TYPE_ORDER.indexOf(b[1]["level"] || "warning"),
),
);
}
}
export class ActionableErrorsField extends ActionableErrors {
static props = { ...standardFieldProps };
get errorData() {
return this.props.record.data[this.props.name];
}
}
export const actionableErrorsField = {component: ActionableErrorsField};
registry.category("fields").add("actionable_errors", actionableErrorsField);

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="account.ActionableErrors">
<t t-if="this.sortedActionableErrors">
<div class="mb-2 rounded-2 overflow-hidden d-grid gap-2">
<t t-foreach="this.sortedActionableErrors" t-as="error" t-key="error">
<t t-set="level" t-value="error_value.level || 'warning'"/>
<div t-att-class="`alert alert-${level} m-0 p-1 ps-3`" role="alert">
<div t-att-name="error" style="white-space: pre-wrap;">
<t t-out="error_value.message"/>
<a class="fw-bold"
t-if="error_value.action or error_value.action_call"
href="#"
t-on-click.prevent="() => this.handleOnClick(error_value)"
>
<i class="oi oi-arrow-right ms-1"/>
<span class="ms-1" t-out="error_value.action_text"/>
<i t-if="level === 'danger'" class="fa fa-warning ms-1"/>
</a>
</div>
</div>
</t>
</div>
</t>
</t>
</templates>

View file

@ -0,0 +1,17 @@
import { registry } from "@web/core/registry";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
export class AutoSaveResPartnerField extends X2ManyField {
async onAdd({ context, editable } = {}) {
await this.props.record.model.root.save();
await super.onAdd({ context, editable });
}
}
export const autoSaveResPartnerField = {
...x2ManyField,
component: AutoSaveResPartnerField,
};
registry.category("fields").add("auto_save_res_partner", autoSaveResPartnerField);

View file

@ -1,49 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2ManyTagsField } from "@web/views/fields/many2many_tags/many2many_tags_field";
const { onWillUpdateProps } = owl;
export class AutosaveMany2ManyTagsField extends Many2ManyTagsField {
setup() {
super.setup();
onWillUpdateProps((nextProps) => this.willUpdateProps(nextProps));
this.lastBalance = this.props.record.data.balance;
this.lastAccount = this.props.record.data.account_id;
this.lastPartner = this.props.record.data.partner_id;
const super_update = this.update;
this.update = (recordlist) => {
super_update(recordlist);
this._saveOnUpdate();
};
}
deleteTag(id) {
super.deleteTag(id);
this._saveOnUpdate();
}
willUpdateProps(nextProps) {
const line = this.props.record.data;
if (line.tax_ids.records.length > 0) {
if (line.balance !== this.lastBalance
|| line.account_id[0] !== this.lastAccount[0]
|| line.partner_id[0] !== this.lastPartner[0]) {
this.lastBalance = line.balance;
this.lastAccount = line.account_id;
this.lastPartner = line.partner_id;
this._saveOnUpdate();
}
}
}
async _saveOnUpdate() {
await this.props.record.model.root.save({ stayInEdition: true });
}
}
registry.category("fields").add("autosave_many2many_tags", AutosaveMany2ManyTagsField);

View file

@ -0,0 +1,53 @@
import { registry } from "@web/core/registry";
import { useRecordObserver } from "@web/model/relational_model/utils";
import {
Many2ManyTaxTagsField,
many2ManyTaxTagsField
} from "@account/components/many2x_tax_tags/many2x_tax_tags";
export class AutosaveMany2ManyTaxTagsField extends Many2ManyTaxTagsField {
setup() {
super.setup();
this.lastBalance = this.props.record.data.balance;
this.lastAccount = this.props.record.data.account_id;
this.lastPartner = this.props.record.data.partner_id;
const super_update = this.update;
this.update = (recordlist) => {
super_update(recordlist);
this._saveOnUpdate();
};
useRecordObserver(this.onRecordChange.bind(this));
}
async deleteTag(id) {
await super.deleteTag(id);
await this._saveOnUpdate();
}
onRecordChange(record) {
const line = record.data;
if (line.tax_ids.records.length > 0) {
if (line.balance !== this.lastBalance
|| line.account_id.id !== this.lastAccount.id
|| line.partner_id.id !== this.lastPartner.id) {
this.lastBalance = line.balance;
this.lastAccount = line.account_id;
this.lastPartner = line.partner_id;
return record.model.root.save();
}
}
}
async _saveOnUpdate() {
await this.props.record.model.root.save();
}
}
export const autosaveMany2ManyTaxTagsField = {
...many2ManyTaxTagsField,
component: AutosaveMany2ManyTaxTagsField,
};
registry.category("fields").add("autosave_many2many_tax_tags", autosaveMany2ManyTaxTagsField);

View file

@ -0,0 +1,69 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { DocumentFileUploader } from "../document_file_uploader/document_file_uploader";
import { Component, onWillStart } from "@odoo/owl";
export class BillGuide extends Component {
static template = "account.BillGuide";
static components = {
DocumentFileUploader,
};
static props = ["*"]; // could contain view_widget props
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.context = null;
this.alias = null;
this.showSampleAction = false;
onWillStart(this.onWillStart);
}
async onWillStart() {
const rec = this.props.record;
const ctx = this.env.searchModel.context;
if (rec) {
// prepare context from journal record
this.context = {
default_journal_id: rec.resId,
default_move_type: (rec.data.type === 'sale' && 'out_invoice') || (rec.data.type === 'purchase' && 'in_invoice') || 'entry',
active_model: rec.resModel,
active_ids: [rec.resId],
}
this.alias = rec.data.alias_domain_id && rec.data.alias_id[1] || false;
} else if (!ctx?.default_journal_id && ctx?.active_id) {
this.context = {
default_journal_id: ctx.active_id,
}
}
this.showSampleAction = await this.orm.call("account.journal", "is_sample_action_available");
}
handleButtonClick(action, model="account.journal") {
this.action.doActionButton({
resModel: model,
name: action,
context: this.context || this.env.searchModel.context,
type: 'object',
});
}
openVendorBill() {
return this.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
views: [[false, "form"]],
context: {
default_move_type: "in_invoice",
},
});
}
}
export const billGuide = {
component: BillGuide,
};
registry.category("view_widgets").add("bill_upload_guide", billGuide);

View file

@ -0,0 +1,35 @@
.o_view_nocontent {
.o_nocontent_help:has(> .bill_guide_container) {
min-width: 65vw;
}
}
.bill_guide_container {
@include media-breakpoint-up(sm) {
min-width: 400px;
}
.bill_guide_left, .bill_guide_right {
width: 45%;
}
.separator_wrapper {
width: 10%;
}
}
.bill-guide-img {
width: 100%;
height: 100%;
object-fit: contain;
}
.mb-9 {
margin-bottom: 9rem !important;
}
.account_drag_drop_btn {
border-style: dashed !important;
border-color: $o-brand-primary;
background-color: mix($o-brand-primary, $o-view-background-color, 15%);
}

View file

@ -0,0 +1,82 @@
<templates>
<t t-name="account.BillGuide">
<div class="d-flex flex-row bill_guide_container mb-3" t-att-class="{ 'mb-9': props.largeIcons }">
<div class="bill_guide_left d-flex align-items-center justify-content-center py-3">
<div>
<div class="text-center">
<img t-att-class="{ 'bill-guide-img': props.largeIcons }" src="/web/static/img/folder.svg"/>
</div>
<div class="text-center mt-2">
<span class="btn account_drag_drop_btn pe-none" t-att-class="{ 'btn-lg': props.largeIcons }">Drag &amp; drop</span>
<div t-if="showSampleAction">
<span class="btn pe-none px-1 fw-normal">or</span>
<a class="btn btn-link px-0 fw-normal"
t-att-class="{ 'btn-lg': props.largeIcons }"
href="#"
type="object"
name="action_create_vendor_bill"
journal_type="purchase"
groups="account.group_account_invoice"
t-on-click="() => this.handleButtonClick('action_create_vendor_bill')"
>
try our sample
</a>
</div>
</div>
</div>
</div>
<div class="separator_wrapper d-flex justify-content-center flex-shrink-1">
<div class="word-separator d-flex flex-column align-items-center">
<div class="vertical-line border-start flex-grow-1 mt-2"/>
<div class="m-2">
or
</div>
<div class="vertical-line border-start flex-grow-1 mb-2"/>
</div>
</div>
<div class="bill_guide_right d-flex align-items-center justify-content-center py-3">
<div t-if="alias">
<div class="text-center">
<img t-att-class="{ 'bill-guide-img': props.largeIcons }" src="/account/static/src/img/bill.svg" alt="Email bills"/>
</div>
<div class="text-center mt-2">
<div class="">
<span class="btn pe-none px-1 fw-normal">Send a bill to</span>
<a class="btn btn-link px-0 fw-normal" t-attf-href="mailto:{{alias}}" t-out="alias"></a>
</div>
<div>
<span class="btn pe-none px-1 fw-normal">or</span>
<a href="#"
type="object"
class="btn btn-link px-0 fw-normal"
t-on-click="() => this.handleButtonClick('action_create_new')"
groups="account.group_account_invoice"
>
Create manually
</a>
</div>
</div>
</div>
<div t-else="">
<div class="text-center">
<img t-att-class="{ 'bill-guide-img': props.largeIcons }" src="/web/static/img/bill.svg" alt="Create bill manually"/>
</div>
<div class="text-center mt-2">
<div class="">
<a href="#"
type="object"
class="o_invoice_new"
t-on-click="() => this.openVendorBill()"
groups="account.group_account_invoice"
>
Create a bill manually
</a>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -1,229 +0,0 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { listView } from "@web/views/list/list_view";
import { ListRenderer } from "@web/views/list/list_renderer";
import { ListController } from "@web/views/list/list_controller";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { KanbanController } from "@web/views/kanban/kanban_controller";
import { KanbanDropdownMenuWrapper } from "@web/views/kanban/kanban_dropdown_menu_wrapper";
import { KanbanRecord } from "@web/views/kanban/kanban_record";
import { FileUploader } from "@web/views/fields/file_handler";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { Component, useState } from "@odoo/owl";
export class AccountFileUploader extends Component {
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.attachmentIdsToProcess = [];
const rec = this.props.record ? this.props.record.data : false;
this.extraContext = rec ? {
default_journal_id: rec.id,
default_move_type: (rec.type === 'sale' && 'out_invoice') || (rec.type === 'purchase' && 'in_invoice') || 'entry',
} : this.props.extraContext || {}; //TODO remove this.props.extraContext
}
async onFileUploaded(file) {
const att_data = {
name: file.name,
mimetype: file.type,
datas: file.data,
};
const att_id = await this.orm.create("ir.attachment", [att_data], {
context: { ...this.extraContext, ...this.env.searchModel.context },
});
this.attachmentIdsToProcess.push(att_id);
}
async onUploadComplete() {
let action;
try {
action = await this.orm.call(
"account.journal",
"create_document_from_attachment",
["", this.attachmentIdsToProcess],
{ context: { ...this.extraContext, ...this.env.searchModel.context } },
);
} finally {
// ensures attachments are cleared on success as well as on error
this.attachmentIdsToProcess = [];
}
if (action.context && action.context.notifications) {
for (let [file, msg] of Object.entries(action.context.notifications)) {
this.notification.add(
msg,
{
title: file,
type: "info",
sticky: true,
});
}
delete action.context.notifications;
}
this.action.doAction(action);
}
}
AccountFileUploader.components = {
FileUploader,
};
AccountFileUploader.template = "account.AccountFileUploader";
AccountFileUploader.extractProps = ({ attrs }) => ({
togglerTemplate: attrs.template || "account.JournalUploadLink",
btnClass: attrs.btnClass || "",
linkText: attrs.linkText || attrs.title || _lt("Upload"), //TODO: remove linkText attr in master (not translatable)
});
AccountFileUploader.props = {
...standardWidgetProps,
record: { type: Object, optional: true},
togglerTemplate: { type: String, optional: true },
btnClass: { type: String, optional: true },
linkText: { type: String, optional: true },
slots: { type: Object, optional: true },
extraContext: { type: Object, optional: true }, //this prop is only for stable databases with the old journal dashboard view, it should be deleted in master as it is not used
}
//when file uploader is used on account.journal (with a record)
AccountFileUploader.fieldDependencies = {
id: { type: "integer" },
type: { type: "selection" },
};
registry.category("view_widgets").add("account_file_uploader", AccountFileUploader);
export class AccountDropZone extends Component {
setup() {
this.notificationService = useService("notification");
}
onDrop(ev) {
const selector = '.account_file_uploader.o_input_file.o_hidden';
// look for the closest uploader Input as it may have a context
let uploadInput = ev.target.closest('.o_drop_area').parentElement.querySelector(selector) || document.querySelector(selector);
let files = ev.dataTransfer ? ev.dataTransfer.files : false;
if (uploadInput && !!files) {
uploadInput.files = ev.dataTransfer.files;
uploadInput.dispatchEvent(new Event("change"));
} else {
this.notificationService.add(
this.env._t("Could not upload files"),
{
type: "danger",
});
}
this.props.hideZone();
}
}
AccountDropZone.defaultProps = {
hideZone: () => {},
};
AccountDropZone.template = "account.DropZone";
// Account Move List View
export class AccountMoveUploadListRenderer extends ListRenderer {
setup() {
super.setup();
this.state.dropzoneVisible = false;
}
}
AccountMoveUploadListRenderer.template = "account.ListRenderer";
AccountMoveUploadListRenderer.components = {
...ListRenderer.components,
AccountDropZone,
};
export class AccountMoveListController extends ListController {
setup() {
super.setup();
this.account_move_service = useService("account_move");
}
async onDeleteSelectedRecords() {
const selectedResIds = await this.getSelectedResIds();
if (this.props.resModel !== "account.move" || !await this.account_move_service.addDeletionDialog(this, selectedResIds)) {
return super.onDeleteSelectedRecords(...arguments);
}
}
};
AccountMoveListController.components = {
...ListController.components,
AccountFileUploader,
};
export const AccountMoveUploadListView = {
...listView,
Controller: AccountMoveListController,
Renderer: AccountMoveUploadListRenderer,
buttonTemplate: "account.ListView.Buttons",
};
// Account Move Kanban View
export class AccountMoveUploadKanbanRenderer extends KanbanRenderer {
setup() {
super.setup();
this.state.dropzoneVisible = false;
}
}
AccountMoveUploadKanbanRenderer.template = "account.KanbanRenderer";
AccountMoveUploadKanbanRenderer.components = {
...KanbanRenderer.components,
AccountDropZone,
};
export class AccountMoveUploadKanbanController extends KanbanController {}
AccountMoveUploadKanbanController.components = {
...KanbanController.components,
AccountFileUploader,
};
export const AccountMoveUploadKanbanView = {
...kanbanView,
Controller: AccountMoveUploadKanbanController,
Renderer: AccountMoveUploadKanbanRenderer,
buttonTemplate: "account.KanbanView.Buttons",
};
// Accounting Dashboard
export class DashboardKanbanDropdownMenuWrapper extends KanbanDropdownMenuWrapper {
onClick(ev) {
// Keep the dropdown open as we need the fileuploader to remain in the dom
if (!ev.target.tagName === "INPUT" && !ev.target.closest('.file_upload_kanban_action_a')) {
super.onClick(ev);
}
}
}
export class DashboardKanbanRecord extends KanbanRecord {
setup() {
super.setup();
this.state = useState({
dropzoneVisible: false,
});
}
}
DashboardKanbanRecord.components = {
...DashboardKanbanRecord.components,
AccountDropZone,
AccountFileUploader,
KanbanDropdownMenuWrapper: DashboardKanbanDropdownMenuWrapper,
};
DashboardKanbanRecord.template = "account.DashboardKanbanRecord";
export class DashboardKanbanRenderer extends KanbanRenderer {}
DashboardKanbanRenderer.components = {
...KanbanRenderer.components,
KanbanRecord: DashboardKanbanRecord,
};
export const DashboardKanbanView = {
...kanbanView,
Renderer: DashboardKanbanRenderer,
};
registry.category("views").add("account_tree", AccountMoveUploadListView);
registry.category("views").add("account_documents_kanban", AccountMoveUploadKanbanView);
registry.category("views").add("account_dashboard_kanban", DashboardKanbanView);

View file

@ -1,27 +0,0 @@
.o_drop_area {
width: 100%;
height: 100%;
position: absolute;
background-color: #AAAA;
z-index: 2;
left: 0;
top: 0;
i {
justify-content: center;
display: flex;
align-items: center;
height: 100%;
}
}
.file_upload_kanban_action_a {
@include o-kanban-dashboard-dropdown-link($link-padding-gap: $o-kanban-dashboard-dropdown-complex-gap);
}
.o_widget_account_file_uploader {
.btn-primary.oe_kanban_action_button {
a {
color: $white;
}
}
}

View file

@ -1,102 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="account.DropZone" owl="1">
<div t-if="props.visible"
t-attf-class="o_drop_area"
t-on-dragover.prevent="()=>{}"
t-on-dragleave="props.hideZone"
t-on-drop.prevent="onDrop">
<i class="fa fa-upload fa-10x"></i>
</div>
</t>
<t t-name="account.AccountFileUploader" owl="1">
<div t-att-class="props.record and props.record.data ? 'oe_kanban_color_' + props.record.data.color : ''">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
fileUploadClass="'account_file_uploader'"
multiUpload="true"
onUploaded.bind="onFileUploaded"
onUploadComplete.bind="onUploadComplete">
<t t-set-slot="toggler">
<t t-if="props.togglerTemplate" t-call="{{ props.togglerTemplate }}"/>
<t t-else="" t-slot="default"/>
</t>
<t t-slot="extra"/>
</FileUploader>
</div>
</t>
<t t-name="account.ListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary" owl="1">
<xpath expr="//div[@t-ref='root']" position="before">
<AccountDropZone
visible="state.dropzoneVisible"
hideZone="() => state.dropzoneVisible = false"/>
</xpath>
<xpath expr="//div[@t-ref='root']" position="attributes">
<attribute name="t-on-dragenter.stop.prevent">() => state.dropzoneVisible = true</attribute>
</xpath>
</t>
<t t-name="account.KanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
<xpath expr="//div[@t-ref='root']" position="before">
<AccountDropZone
visible="state.dropzoneVisible"
hideZone="() => state.dropzoneVisible = false"/>
</xpath>
<xpath expr="//div[@t-ref='root']" position="attributes">
<attribute name="t-on-dragenter.stop.prevent">() => state.dropzoneVisible = true</attribute>
</xpath>
</t>
<t t-name="account.ListView.Buttons" t-inherit="web.ListView.Buttons" t-inherit-mode="primary" owl="1">
<xpath expr="//*[@class='btn btn-primary o_list_button_add']" position="after">
<t t-call="account.AccountViewUploadButton"/>
</xpath>
</t>
<t t-name="account.KanbanView.Buttons" t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary" owl="1">
<xpath expr="//*[@class='btn btn-primary o-kanban-button-new']" position="after">
<t t-call="account.AccountViewUploadButton"/>
</xpath>
</t>
<t t-name="account.AccountViewUploadButton" owl="1">
<!-- No record is available so rely on the action context to contain the default_move_type -->
<AccountFileUploader>
<t t-set-slot="default">
<button type="button" class="btn btn-secondary o_button_upload_bill">
Upload
</button>
</t>
</AccountFileUploader>
</t>
<t t-name="account.DashboardKanbanRecord" owl="1">
<div
role="article"
t-att-class="getRecordClasses()"
t-att-data-id="props.canResequence and props.record.id"
t-att-tabindex="props.record.model.useSampleModel ? -1 : 0"
t-on-click="onGlobalClick"
t-on-dragenter.stop.prevent="() => state.dropzoneVisible = true"
t-ref="root">
<AccountFileUploader record="props.record">
<t t-set-slot="extra">
<AccountDropZone
visible="state.dropzoneVisible"
hideZone="() => state.dropzoneVisible = false"/>
<t t-call="{{ templates['kanban-box'] }}"/>
</t>
</AccountFileUploader>
</div>
</t>
<t t-name="account.JournalUploadLink" owl="1">
<div t-att-class="props.btnClass" groups="account.group_account_invoice">
<a href="#" t-out="props.linkText"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,22 @@
import { registry } from "@web/core/registry";
import { CharField, charField } from "@web/views/fields/char/char_field";
// Ensure that in Hoot tests, this module is loaded after `@mail/js/onchange_on_keydown`
// (needed because that module patches `charField`).
import "@mail/js/onchange_on_keydown";
export class CharWithPlaceholderField extends CharField {
static template = "account.CharWithPlaceholderField";
/** Override **/
get formattedValue() {
return super.formattedValue || this.props.placeholder;
}
}
export const charWithPlaceholderField = {
...charField,
component: CharWithPlaceholderField,
};
registry.category("fields").add("char_with_placeholder_field", charWithPlaceholderField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.CharWithPlaceholderField" t-inherit="web.CharField">
<xpath expr="//span" position="attributes">
<attribute name="t-att-class">{'text-muted': !this.props.record.data[props.name]}</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.CharWithPlaceholderFieldToCheck" t-inherit="account.CharWithPlaceholderField" t-inherit-mode="extension">
<xpath expr="//span" position="after">
<span t-if="props.record.data.checked === false and props.record.data.state === 'posted'"
groups="account.group_account_user"
class="badge rounded-pill text-bg-info mx-2 d-inline-flex">
To review
</span>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,16 @@
import { registry } from "@web/core/registry";
import {
charWithPlaceholderField,
CharWithPlaceholderField
} from "../char_with_placeholder_field/char_with_placeholder_field";
export class CharWithPlaceholderFieldToCheck extends CharWithPlaceholderField {
static template = "account.CharWithPlaceholderField";
}
export const charWithPlaceholderFieldToCheck = {
...charWithPlaceholderField,
component: CharWithPlaceholderFieldToCheck,
};
registry.category("fields").add("char_with_placeholder_field_to_check", charWithPlaceholderFieldToCheck);

View file

@ -0,0 +1,43 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { registry } from "@web/core/registry";
import { FormController } from "@web/views/form/form_controller";
import { formView } from "@web/views/form/form_view";
export class CurrencyFormController extends FormController {
async onWillSaveRecord(record) {
if (record.data.display_rounding_warning &&
record._values.rounding !== undefined &&
record.data.rounding < record._values.rounding
) {
return new Promise((resolve) => {
this.dialogService.add(ConfirmationDialog, {
title: _t("Confirmation Warning"),
body: _t(
"You're about to permanently change the decimals for all prices in your database.\n" +
"This change cannot be undone without technical support."
),
confirmLabel: _t("Confirm"),
cancelLabel: _t("Cancel"),
confirm: () => resolve(true),
cancel: () => {
record.discard();
resolve(false);
},
});
});
}
return true;
}
}
export const currencyFormView = {
...formView,
Controller: CurrencyFormController,
};
registry.category("views").add("currency_form", currencyFormView);

View file

@ -0,0 +1,24 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
class OpenDecimalPrecisionButton extends Component {
static template = "account.OpenDecimalPrecisionButton";
static props = { ...standardFieldProps };
setup() {
this.action = useService("action");
}
async discardAndOpen() {
await this.props.record.discard();
this.action.doAction("base.action_decimal_precision_form");
}
}
registry.category("fields").add("open_decimal_precision_button", {
component: OpenDecimalPrecisionButton,
});

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.OpenDecimalPrecisionButton">
<button type="button"
class="btn btn-link p-0"
t-on-click="discardAndOpen">
<i class="fa fa-arrow-right text-muted me-1"/>
More precision on Product Prices
</button>
</t>
</templates>

View file

@ -0,0 +1,78 @@
import { useService } from "@web/core/utils/hooks";
import { FileUploader } from "@web/views/fields/file_handler";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { Component, markup } from "@odoo/owl";
export class DocumentFileUploader extends Component {
static template = "account.DocumentFileUploader";
static components = {
FileUploader,
};
static props = {
...standardWidgetProps,
record: { type: Object, optional: true },
slots: { type: Object, optional: true },
resModel: { type: String, optional: true },
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.attachmentIdsToProcess = [];
this.extraContext = this.getExtraContext();
}
// To pass extra context while creating record
getExtraContext() {
return {};
}
async onFileUploaded(file) {
const att_data = {
name: file.name,
mimetype: file.type,
datas: file.data,
};
// clean the context to ensure the `create` call doesn't fail from unknown `default_*` context
const cleanContext = Object.fromEntries(Object.entries(this.env.searchModel.context).filter(([key]) => !key.startsWith('default_')));
const [att_id] = await this.orm.create("ir.attachment", [att_data], {context: cleanContext});
this.attachmentIdsToProcess.push(att_id);
}
// To define specific resModal from another model
getResModel() {
return this.props.resModel;
}
async onUploadComplete() {
const resModal = this.getResModel();
let action;
try {
action = await this.orm.call(
resModal,
"create_document_from_attachment",
["", this.attachmentIdsToProcess],
{ context: { ...this.extraContext, ...this.env.searchModel.context } }
);
} finally {
// ensures attachments are cleared on success as well as on error
this.attachmentIdsToProcess = [];
}
if (action.context && action.context.notifications) {
for (const [file, msg] of Object.entries(action.context.notifications)) {
this.notification.add(msg, {
title: file,
type: "info",
sticky: true,
});
}
delete action.context.notifications;
}
if (action.help?.length) {
action.help = markup(action.help);
}
this.action.doAction(action);
}
}

View file

@ -0,0 +1,29 @@
<templates>
<t t-name="account.DocumentFileUploader">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
fileUploadClass="'document_file_uploader'"
multiUpload="true"
onUploaded.bind="onFileUploaded"
onUploadComplete.bind="onUploadComplete">
<t t-set-slot="toggler">
<t t-slot="toggler"/>
</t>
<t t-slot="default"/>
</FileUploader>
</t>
<t t-name="account.DocumentViewUploadButton">
<DocumentFileUploader resModel="props.resModel">
<t t-set-slot="toggler">
<t t-if="!hideUploadButton">
<button type="button" class="btn btn-secondary" data-hotkey="shift+i">
Upload
</button>
</t>
</t>
</DocumentFileUploader>
</t>
</templates>

View file

@ -0,0 +1,69 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field";
import { Component } from "@odoo/owl";
export class DocumentStatePopover extends Component {
static template = "account.DocumentStatePopover";
static props = {
close: Function,
onClose: Function,
copyText: Function,
message: String,
};
}
export class DocumentState extends SelectionField {
static template = "account.DocumentState";
setup() {
super.setup();
this.popover = useService("popover");
this.notification = useService("notification");
}
get message() {
return this.props.record.data.message;
}
copyText() {
navigator.clipboard.writeText(this.message);
this.notification.add(_t("Text copied"), { type: "success" });
this.popoverCloseFn();
this.popoverCloseFn = null;
}
showMessagePopover(ev) {
const close = () => {
this.popoverCloseFn();
this.popoverCloseFn = null;
};
if (this.popoverCloseFn) {
close();
return;
}
this.popoverCloseFn = this.popover.add(
ev.currentTarget,
DocumentStatePopover,
{
message: this.message,
copyText: this.copyText.bind(this),
onClose: close,
},
{
closeOnClickAway: true,
position: "top",
},
);
}
}
registry.category("fields").add("account_document_state", {
...selectionField,
component: DocumentState,
});

View file

@ -0,0 +1,10 @@
.account_document_state_popover {
width: 500px;
}
.account_document_state_popover_clone {
&:hover {
color: $o-enterprise-action-color !important;
cursor: pointer;
}
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.DocumentStatePopover">
<div class="row m-2 mt-4 justify-content-between account_document_state_popover">
<span class="col-10" t-out="props.message" style="white-space: pre-wrap;"/>
<button class="col-2 btn p-0 account_document_state_popover_clone" t-on-click="() => props.copyText()">
<i class="fa fa-clipboard"/>
</button>
</div>
</t>
<t t-name="account.DocumentState" t-inherit="web.SelectionField" t-inherit-mode="primary">
<span position="after">
<span t-if="message"> </span>
<a t-if="message"
t-on-click="(ev) => this.showMessagePopover(ev)"
class="fa fa-info-circle"/>
</span>
</t>
</templates>

View file

@ -0,0 +1,63 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field";
export class DynamicSelectionField extends SelectionField {
static props = {
...SelectionField.props,
available_field: { type: String },
}
get availableOptions() {
return this.props.record.data[this.props.available_field]?.split(",") || [];
}
/**
* Filter the options with the accepted available options.
* @override
*/
get options() {
const availableOptions = this.availableOptions;
return super.options.filter(x => availableOptions.includes(x[0]));
}
/**
* In dynamic selection field, sometimes we can have no options available.
* This override handles that case by adding optional chaining when accessing the found options.
* @override
*/
get string() {
if (this.type === "selection") {
return this.props.record.data[this.props.name] !== false
? this.options.find((o) => o[0] === this.props.record.data[this.props.name])?.[1]
: "";
}
return super.string;
}
}
/*
EXAMPLE USAGE:
In python:
the_available_field = fields.Char() # string of comma separated available selection field keys
the_selection_field = fields.Selection([ ... ])
In the views:
<field name="the_available_field" column_invisible="1"/>
<field name="the_selection_field"
widget="dynamic_selection"
options="{'available_field': 'the_available_field'}"/>
*/
registry.category("fields").add("dynamic_selection", {
...selectionField,
component: DynamicSelectionField,
extractProps: (fieldInfo, dynamicInfo) => ({
...selectionField.extractProps(fieldInfo, dynamicInfo),
available_field: fieldInfo.options.available_field,
}),
})

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="account.FetchEInvoices">
<DropdownItem onSelected.bind="fetchEInvoices">
<i class="fa fa-fw fa-refresh me-1" aria-hidden="true"></i><t t-esc="this.buttonLabel" />
</DropdownItem>
</t>
</templates>

View file

@ -0,0 +1,59 @@
import { Component } from "@odoo/owl";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
import { ACTIONS_GROUP_NUMBER } from "@web/search/action_menus/action_menus";
const cogMenuRegistry = registry.category("cogMenu");
export class FetchEInvoices extends Component {
static template = "account.FetchEInvoices";
static props = {};
static components = { DropdownItem };
setup() {
super.setup();
this.action = useService("action");
}
get buttonAction() {
return this.env.searchModel.globalContext.show_fetch_in_einvoices_button
? "button_fetch_in_einvoices"
: "button_refresh_out_einvoices_status";
}
get buttonLabel() {
return this.env.searchModel.globalContext.show_fetch_in_einvoices_button
? _t("Fetch e-Invoices")
: _t("Refresh e-Invoices Status");
}
fetchEInvoices() {
const journalId = this.env.searchModel.globalContext.default_journal_id;
if (!journalId) {
return;
}
this.action.doActionButton({
type: "object",
resId: journalId,
name: this.buttonAction,
resModel: "account.journal",
onClose: () => window.location.reload(),
});
}
}
export const fetchEInvoicesActionMenu = {
Component: FetchEInvoices,
groupNumber: ACTIONS_GROUP_NUMBER,
isDisplayed: ({ config, searchModel }) =>
searchModel.resModel === "account.move" &&
(searchModel.globalContext.default_journal_id || false) &&
(searchModel.globalContext.show_fetch_in_einvoices_button ||
searchModel.globalContext.show_refresh_out_einvoices_status_button ||
false),
};
cogMenuRegistry.add("account-fetch-e-invoices", fetchEInvoicesActionMenu, { sequence: 11 });

View file

@ -1,31 +1,30 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
const { Component, onWillUpdateProps } = owl;
class ListItem extends Component {
static template = "account.GroupedItemTemplate";
static props = ["item_vals", "options"];
}
class ListItem extends Component {}
ListItem.template = "account.GroupedItemTemplate";
ListItem.props = ["item_vals", "options"];
class ListGroup extends Component {}
ListGroup.template = "account.GroupedItemsTemplate";
ListGroup.components = { ListItem };
ListGroup.props = ["group_vals", "options"];
class ListGroup extends Component {
static template = "account.GroupedItemsTemplate";
static components = { ListItem };
static props = ["group_vals", "options"];
}
class ShowGroupedList extends Component {
setup() {
this.formatData(this.props);
onWillUpdateProps((nextProps) => this.formatData(nextProps));
}
formatData(props) {
this.data = props.value
? JSON.parse(props.value)
static template = "account.GroupedListTemplate";
static components = { ListGroup };
static props = {...standardFieldProps};
getValue() {
const value = this.props.record.data[this.props.name];
return value
? JSON.parse(value)
: { groups_vals: [], options: { discarded_number: "", columns: [] } };
}
}
ShowGroupedList.template = "account.GroupedListTemplate";
ShowGroupedList.components = { ListGroup };
registry.category("fields").add("grouped_view_widget", ShowGroupedList);
registry.category("fields").add("grouped_view_widget", {
component: ShowGroupedList,
});

View file

@ -1,25 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.GroupedListTemplate" owl="1">
<table t-if="data.groups_vals.length" class="table table-sm o_list_table table table-sm table-hover table-striped o_list_table_grouped">
<t t-name="account.GroupedListTemplate">
<t t-set="value" t-value="this.getValue()"/>
<table t-if="value.groups_vals.length" class="table table-sm o_list_table table table-sm table-hover table-striped o_list_table_grouped">
<thead><tr>
<t t-foreach="data.options.columns" t-as="col" t-key="col_index">
<th t-esc="col['label']" t-attf-class="{{col['class']}}"/>
<t t-foreach="value.options.columns" t-as="col" t-key="col_index">
<th t-out="col['label']" t-attf-class="{{col['class']}}"/>
</t>
</tr></thead>
<t t-foreach="data.groups_vals" t-as="group_vals" t-key="group_vals_index">
<ListGroup group_vals="group_vals" options="data.options"/>
<t t-foreach="value.groups_vals" t-as="group_vals" t-key="group_vals_index">
<ListGroup group_vals="group_vals" options="value.options"/>
</t>
</table>
<t t-if="data.options.discarded_number">
<span><t t-esc="data.options.discarded_number"/> are not shown in the preview</span>
<t t-if="value.options.discarded_number">
<span><t t-out="value.options.discarded_number"/> are not shown in the preview</span>
</t>
</t>
<tbody t-name="account.GroupedItemsTemplate" owl="1">
<tbody t-name="account.GroupedItemsTemplate">
<tr style="background-color: #dee2e6;">
<td t-attf-colspan="{{props.options.columns.length}}">
<t t-esc="props.group_vals.group_name"/>
<t t-out="props.group_vals.group_name"/>
</td>
</tr>
<t t-foreach="props.group_vals.items_vals" t-as="item_vals" t-key="item_vals_index">
@ -27,14 +28,14 @@
</t>
</tbody>
<tr t-name="account.GroupedItemTemplate" owl="1">
<tr t-name="account.GroupedItemTemplate">
<t t-foreach="props.options.columns" t-as="col" t-key="col_index">
<td t-esc="props.item_vals[col['field']]" t-attf-class="{{col['class']}}"/>
<td t-out="props.item_vals[col['field']]" t-attf-class="{{col['class']}}"/>
</t>
</tr>
<t t-name="account.OpenMoveTemplate">
<a href="#" t-esc="widget.value"/>
<a href="#" t-out="widget.value"/>
</t>
</templates>

View file

@ -1,48 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const { Component } = owl;
export class JournalDashboardActivity extends Component {
setup() {
this.action = useService("action");
this.MAX_ACTIVITY_DISPLAY = 5;
this.formatData(this.props);
}
formatData(props) {
this.info = JSON.parse(this.props.value);
this.info.more_activities = false;
if (this.info.activities.length > this.MAX_ACTIVITY_DISPLAY) {
this.info.more_activities = true;
this.info.activities = this.info.activities.slice(0, this.MAX_ACTIVITY_DISPLAY);
}
}
async openActivity(activity) {
this.action.doAction({
type: 'ir.actions.act_window',
name: this.env._t('Journal Entry'),
target: 'current',
res_id: activity.res_id,
res_model: 'account.move',
views: [[false, 'form']],
});
}
openAllActivities(e) {
this.action.doAction({
type: 'ir.actions.act_window',
name: this.env._t('Journal Entries'),
res_model: 'account.move',
views: [[false, 'kanban'], [false, 'form']],
search_view_id: [false],
domain: [['journal_id', '=', this.props.record.resId], ['activity_ids', '!=', false]],
});
}
}
JournalDashboardActivity.template = "account.JournalDashboardActivity";
registry.category("fields").add("kanban_vat_activity", JournalDashboardActivity);

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="account.JournalDashboardActivity" owl="1">
<t t-foreach="info.activities" t-as="activity" t-key="activity_index">
<div class="row">
<div class="col-8 o_mail_activity">
<a href="#"
t-att-class="(activity.status == 'late' ? 'o_activity_color_overdue ' : ' ') + (activity.activity_category == 'tax_report' ? 'o_open_vat_report' : 'see_activity')"
t-att-data-res-id="activity.res_id" t-att-data-id="activity.id" t-att-data-model="activity.res_model"
t-on-click.stop.prevent="() => this.openActivity(activity)">
<t t-esc="activity.name"/>
</a>
</div>
<div class="col-4 text-end">
<span><t t-esc="activity.date"/></span>
</div>
</div>
</t>
<a t-if="info.more_activities" class="float-end see_all_activities" href="#" t-on-click.stop.prevent="(ev) => this.openAllActivities(ev)">See all activities</a>
</t>
</templates>

View file

@ -0,0 +1,74 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { FileInput } from "@web/core/file_input/file_input";
import { Component, onWillUnmount } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class MailAttachments extends Component {
static template = "account.mail_attachments";
static components = { FileInput };
static props = {...standardFieldProps};
setup() {
this.orm = useService("orm");
this.notification = useService("notification");
this.attachmentIdsToUnlink = new Set();
onWillUnmount(this.onWillUnmount);
}
get attachments() {
return this.props.record.data[this.props.name] || [];
}
get renderedAttachments() {
const attachments = JSON.parse(JSON.stringify(this.attachments));
const attachmentsNotSupported = this.props.record.data.attachments_not_supported || {};
for (const attachment of attachments) {
if (attachment.id && attachment.id in attachmentsNotSupported) {
attachment.tooltip = attachmentsNotSupported[attachment.id];
}
}
return attachments;
}
onFileRemove(deleteId) {
const newValue = [];
for (let item of this.attachments) {
if (item.id === deleteId) {
if (item.placeholder || item.protect_from_deletion) {
const copyItem = Object.assign({ skip: true }, item);
newValue.push(copyItem);
} else {
this.attachmentIdsToUnlink.add(item.id);
}
} else {
newValue.push(item);
}
}
this.props.record.update({ [this.props.name]: newValue });
}
async onWillUnmount() {
// Unlink added attachments if the wizard is not saved.
if (!this.props.record.resId) {
this.attachments.forEach((item) => {
if (item.manual) {
this.attachmentIdsToUnlink.add(item.id);
}
});
}
if (this.attachmentIdsToUnlink.size) {
await this.orm.unlink("ir.attachment", Array.from(this.attachmentIdsToUnlink));
}
}
}
export const mailAttachments = {
component: MailAttachments,
};
registry.category("fields").add("mail_attachments", mailAttachments);

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="account.mail_attachments">
<ul class="list-unstyled m-0">
<t t-foreach="renderedAttachments" t-as="attachment" t-key="attachment.id">
<t t-if="!attachment.skip">
<li class="d-flex align-items-center bg-200 p-1 ps-3 my-2">
<span t-out="attachment.name" class="flex-grow-1 text-truncate"/>
<button class="btn flex-shrink-0" t-on-click.stop="() => this.onFileRemove(attachment.id)">
<i class="fa fa-fw fa-times"/>
</button>
<i class="fa fa-fw o_button_icon fa-warning" t-if="attachment.tooltip" t-att-data-tooltip="attachment.tooltip"></i>
</li>
</t>
</t>
</ul>
</t>
</templates>

View file

@ -0,0 +1,51 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { FileUploader } from "@web/views/fields/file_handler";
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { useX2ManyCrud } from "@web/views/fields/relational_utils";
import { dataUrlToBlob } from "@mail/core/common/attachment_uploader_hook";
export class MailAttachments extends Component {
static template = "mail.MailComposerAttachmentSelector";
static components = { FileUploader };
static props = {...standardFieldProps};
setup() {
this.mailStore = useService("mail.store");
this.attachmentUploadService = useService("mail.attachment_upload");
this.operations = useX2ManyCrud(() => {
return this.props.record.data["attachment_ids"];
}, true);
}
get attachments() {
return this.props.record.data[this.props.name] || [];
}
async onFileUploaded({ name, data, type }) {
const resIds = JSON.parse(this.props.record.data.res_ids);
const thread = await this.mailStore.Thread.insert({
model: this.props.record.data.model,
id: resIds[0],
});
const file = new File([dataUrlToBlob(data, type)], name, { type });
const attachment = await this.attachmentUploadService.upload(thread, thread.composer, file);
let fileDict = {
id: attachment.id,
name: attachment.name,
mimetype: attachment.mimetype,
placeholder: false,
manual: true,
};
this.props.record.update({ [this.props.name]: this.attachments.concat([fileDict]) });
}
}
export const mailAttachments = {
component: MailAttachments,
};
registry.category("fields").add("mail_attachments_selector", mailAttachments);

View file

@ -0,0 +1,80 @@
import {
many2ManyTagsFieldColorEditable,
Many2ManyTagsFieldColorEditable,
} from "@web/views/fields/many2many_tags/many2many_tags_field";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { TagsList } from "@web/core/tags_list/tags_list";
import { _t } from "@web/core/l10n/translation";
import { onMounted } from "@odoo/owl";
export class FieldMany2ManyTagsBanksTagsList extends TagsList {
static template = "FieldMany2ManyTagsBanksTagsList";
}
export class FieldMany2ManyTagsBanks extends Many2ManyTagsFieldColorEditable {
static template = "account.FieldMany2ManyTagsBanks";
static components = {
...FieldMany2ManyTagsBanks.components,
TagsList: FieldMany2ManyTagsBanksTagsList,
};
setup() {
super.setup();
this.actionService = useService("action");
onMounted(async () => {
// Needed when you create a partner (from a move for example), we want the partner to be saved to be able
// to have it as account holder
const isDirty = await this.props.record.model.root.isDirty();
if (isDirty) {
this.props.record.model.root.save();
}
});
}
getTagProps(record) {
return {
...super.getTagProps(record),
allowOutPayment: record.data?.allow_out_payment,
};
}
openBanksListView() {
this.actionService.doAction({
type: "ir.actions.act_window",
name: _t("Banks"),
res_model: this.relation,
views: [
[false, "list"],
[false, "form"],
],
domain: this.getDomain(),
target: "current",
});
}
}
export const fieldMany2ManyTagsBanks = {
...many2ManyTagsFieldColorEditable,
component: FieldMany2ManyTagsBanks,
supportedOptions: [
...(many2ManyTagsFieldColorEditable.supportedOptions || []),
{
label: _t("Allows out payments"),
name: "allow_out_payment_field",
type: "boolean",
},
],
additionalClasses: [
...(many2ManyTagsFieldColorEditable.additionalClasses || []),
"o_field_many2many_tags",
],
relatedFields: ({ options }) => {
return [
...many2ManyTagsFieldColorEditable.relatedFields({ options }),
{ name: options.allow_out_payment_field, type: "boolean", readonly: false },
];
},
};
registry.category("fields").add("many2many_tags_banks", fieldMany2ManyTagsBanks);

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="FieldMany2ManyTagsBanksTagsList" t-inherit="web.TagsList" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_tag_badge_text')]" position="before">
<span class="me-1">
<i t-if="tag.allowOutPayment" class="fa fa-shield text-success" data-tooltip="Trusted"/>
<i t-else="" class="fa fa-exclamation-circle text-danger" data-tooltip="Untrusted"/>
</span>
</xpath>
</t>
<t t-name="account.FieldMany2ManyTagsBanks" t-inherit="web.Many2ManyTagsField" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_field_many2many_selection')]" position="inside">
<button
aria-label="Internal link"
class="btn btn-link text-action o_dropdown_button px-1 py-0 oi oi-arrow-right"
data-tooltip="Internal link"
draggable="false"
tabindex="-1"
type="button"
t-on-click="this.openBanksListView"
/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,56 @@
import { registry } from "@web/core/registry";
import {
Many2ManyTagsField,
many2ManyTagsField,
} from "@web/views/fields/many2many_tags/many2many_tags_field";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
export class Many2ManyTagsJournalsMany2xAutocomplete extends Many2XAutocomplete {
static template = "account.Many2ManyTagsJournalsMany2xAutocomplete";
static props = {
...Many2XAutocomplete.props,
group_company_id: { type: Number, optional: true },
};
get searchSpecification() {
return {
...super.searchSpecification,
company_id: {
fields: {
display_name: {},
},
},
};
}
}
export class Many2ManyTagsJournals extends Many2ManyTagsField {
static template = "account.Many2ManyTagsJournals";
static components = {
...Many2ManyTagsField.components,
Many2XAutocomplete: Many2ManyTagsJournalsMany2xAutocomplete,
};
getTagProps(record) {
const group_company_id = this.props.record.data["company_id"];
const text = group_company_id
? record.data.display_name
: `${record.data.company_id.display_name} - ${record.data.display_name}`;
return {
...super.getTagProps(record),
text,
};
}
}
export const fieldMany2ManyTagsJournals = {
...many2ManyTagsField,
component: Many2ManyTagsJournals,
relatedFields: (fieldInfo) => [
...many2ManyTagsField.relatedFields(fieldInfo),
{ name: "company_id", type: "many2one", relation: "res.company" },
],
};
registry.category("fields").add("many2many_tags_journals", fieldMany2ManyTagsJournals);

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.Many2ManyTagsJournals" t-inherit="web.Many2ManyTagsField">
<xpath expr="//Many2XAutocomplete" position="attributes">
<attribute name="group_company_id">this.props.record.data['company_id'].id</attribute>
</xpath>
</t>
<t t-name="account.Many2ManyTagsJournalsMany2xAutocomplete" t-inherit="web.Many2XAutocomplete" t-inherit-mode="primary">
<xpath expr="//t[@t-set-slot='option']/t" position="after">
<t t-if="optionScope.data.record and !props.group_company_id">
<span class="text-muted ms-3 fst-italic">
<t t-out="optionScope.data.record.company_id.display_name"/>
</span>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,65 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import {
Many2ManyTagsField,
many2ManyTagsField,
} from "@web/views/fields/many2many_tags/many2many_tags_field";
export class Many2XTaxTagsAutocomplete extends Many2XAutocomplete {
static components = {
...Many2XAutocomplete.components,
};
async loadOptionsSource(request) {
// Always include Search More
let options = await super.loadOptionsSource(...arguments);
if (!options.slice(-1)[0]?.cssClass?.includes("o_m2o_dropdown_option_search_more")) {
options.push({
label: this.SearchMoreButtonLabel,
onSelect: this.onSearchMore.bind(this, request),
cssClass: "o_m2o_dropdown_option o_m2o_dropdown_option_search_more",
});
}
return options;
}
async onSearchMore(request) {
const { getDomain, context, fieldString } = this.props;
const domain = getDomain();
let dynamicFilters = [];
if (request.length) {
dynamicFilters = [
{
description: _t("Quick search: %s", request),
domain: [["name", "ilike", request]],
},
];
}
const title = _t("Search: %s", fieldString);
this.selectCreate({
domain,
context,
filters: dynamicFilters,
title,
});
}
}
export class Many2ManyTaxTagsField extends Many2ManyTagsField {
static components = {
...Many2ManyTagsField.components,
Many2XAutocomplete: Many2XTaxTagsAutocomplete,
};
}
export const many2ManyTaxTagsField = {
...many2ManyTagsField,
component: Many2ManyTaxTagsField,
additionalClasses: ['o_field_many2many_tags']
};
registry.category("fields").add("many2many_tax_tags", many2ManyTaxTagsField);

View file

@ -0,0 +1,35 @@
import { registry } from "@web/core/registry";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
class AccountOnboardingWidget extends Component {
static template = "account.Onboarding";
static props = {
...standardWidgetProps,
};
setup() {
this.action = useService("action");
this.orm = useService("orm");
}
get recordOnboardingSteps() {
return JSON.parse(this.props.record.data.kanban_dashboard).onboarding?.steps;
}
async onboardingLinkClicked(step) {
const action = await this.orm.call("onboarding.onboarding.step", step.action, [], {
context: {
journal_id: this.props.record.resId,
}
});
this.action.doAction(action);
}
}
export const accountOnboarding = {
component: AccountOnboardingWidget,
}
registry.category("view_widgets").add("account_onboarding", accountOnboarding);

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template>
<t t-name="account.Onboarding">
<div class="">
<div class="col-auto my-1" t-foreach="recordOnboardingSteps" t-as="step" t-key="step.id">
<i class="fa me-2 fs-5" t-att-class="{
'fa-circle text-secondary': step.state == 'not_done',
'fa-check-circle text-success': step.state != 'not_done',
}"/>
<a href="#"
t-att-data-method="step.action"
data-model="onboarding.onboarding.step"
t-out="step.title"
t-att-title="step.description"
t-on-click.stop.prevent="() => this.onboardingLinkClicked(step)"
/>
</div>
</div>
</t>
</template>

View file

@ -1,17 +1,35 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { Many2OneField } from "@web/views/fields/many2one/many2one_field";
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";
class LineOpenMoveWidget extends Component {
static template = "account.LineOpenMoveWidget";
static components = { Many2One };
static props = { ...Many2OneField.props };
setup() {
this.action = useService("action");
}
get m2oProps() {
return {
...computeM2OProps(this.props),
openRecordAction: () => this.openAction(),
};
}
class LineOpenMoveWidget extends Many2OneField {
async openAction() {
this.action.doActionButton({
return this.action.doActionButton({
type: "object",
resId: this.props.value[0],
resId: this.props.record.data[this.props.name].id,
name: "action_open_business_doc",
resModel: "account.move.line",
});
}
}
registry.category("fields").add("line_open_move_widget", LineOpenMoveWidget);
registry.category("fields").add("line_open_move_widget", {
...buildM2OFieldDescription(LineOpenMoveWidget),
});

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.LineOpenMoveWidget">
<Many2One t-props="m2oProps"/>
</t>
</templates>

View file

@ -1,11 +1,12 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const { Component } = owl;
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Component } from "@odoo/owl";
class OpenMoveWidget extends Component {
static template = "account.OpenMoveWidget";
static props = { ...standardFieldProps };
setup() {
super.setup();
this.action = useService("action");
@ -16,10 +17,11 @@ class OpenMoveWidget extends Component {
type: "object",
resId: this.props.record.resId,
name: "action_open_business_doc",
resModel: "account.move.line",
resModel: this.props.record.resModel,
});
}
}
OpenMoveWidget.template = "account.OpenMoveWidget";
registry.category("fields").add("open_move_widget", OpenMoveWidget);
registry.category("fields").add("open_move_widget", {
component: OpenMoveWidget,
});

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.OpenMoveWidget" owl="1">
<a href="#" t-esc="props.value" t-on-click.prevent.stop="(ev) => this.openMove()"/>
<t t-name="account.OpenMoveWidget">
<a href="#" t-out="props.record.data[props.name] || '/'" t-on-click.prevent.stop="(ev) => this.openMove()"/>
</t>
</templates>

View file

@ -0,0 +1,8 @@
import { ProductCatalogOrderLine } from "@product/product_catalog/order_line/order_line";
export class ProductCatalogAccountMoveLine extends ProductCatalogOrderLine {
static props = {
...ProductCatalogOrderLine.props,
min_qty: { type: Number, optional: true },
};
}

View file

@ -0,0 +1,19 @@
import { ProductCatalogKanbanController } from "@product/product_catalog/kanban_controller";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
patch(ProductCatalogKanbanController.prototype, {
get stateFiels() {
return this.orderResModel === "account.move" ? ["state", "move_type"] : super.stateFiels;
},
_defineButtonContent() {
if (this.orderStateInfo.move_type === "out_invoice") {
this.buttonString = _t("Back to Invoice");
} else if (this.orderStateInfo.move_type === "in_invoice") {
this.buttonString = _t("Back to Bill");
} else {
super._defineButtonContent();
}
},
});

View file

@ -0,0 +1,26 @@
import { ProductCatalogKanbanModel } from "@product/product_catalog/kanban_model";
import { patch } from "@web/core/utils/patch";
patch(ProductCatalogKanbanModel.prototype, {
async _loadData(params) {
const selectedSection = this.env.searchModel.selectedSection;
if (selectedSection.filtered) {
params = {
...params,
domain: [...(params.domain || []), ['is_in_selected_section_of_order', '=', true]],
context: {
...params.context,
section_id: selectedSection.sectionId,
},
};
}
return await super._loadData(params);
},
_getOrderLinesInfoParams(params, productIds) {
return {
...super._getOrderLinesInfoParams(params, productIds),
section_id: this.env.searchModel.selectedSection.sectionId,
};
}
})

View file

@ -0,0 +1,52 @@
import { useSubEnv } from "@odoo/owl";
import { ProductCatalogKanbanRecord } from "@product/product_catalog/kanban_record";
import { ProductCatalogAccountMoveLine } from "./account_move_line";
import { patch } from "@web/core/utils/patch";
patch(ProductCatalogKanbanRecord.prototype, {
setup() {
super.setup();
useSubEnv({
...this.env,
selectedSectionId: this.env.searchModel.selectedSection?.sectionId,
});
},
get orderLineComponent() {
if (this.env.orderResModel === "account.move") {
return ProductCatalogAccountMoveLine;
}
return super.orderLineComponent;
},
_getUpdateQuantityAndGetPriceParams() {
return {
...super._getUpdateQuantityAndGetPriceParams(),
section_id: this.env.searchModel.selectedSection.sectionId || false,
};
},
addProduct(qty = 1) {
if (this.productCatalogData.quantity === 0 && qty < this.productCatalogData.min_qty) {
qty = this.productCatalogData.min_qty; // Take seller's minimum if trying to add less
}
super.addProduct(qty);
},
updateQuantity(quantity) {
const lineCountChange = (quantity > 0) - (this.productCatalogData.quantity > 0);
if (lineCountChange !== 0) {
this.notifyLineCountChange(lineCountChange);
}
super.updateQuantity(quantity);
},
notifyLineCountChange(lineCountChange) {
this.env.searchModel.trigger('section-line-count-change', {
sectionId: this.env.selectedSectionId,
lineCountChange: lineCountChange,
});
},
})

View file

@ -0,0 +1,9 @@
import { productCatalogKanbanView } from "@product/product_catalog/kanban_view";
import { patch } from "@web/core/utils/patch";
import { AccountProductCatalogSearchModel } from "./search/search_model";
import { AccountProductCatalogSearchPanel} from "./search/search_panel";
patch(productCatalogKanbanView, {
SearchModel: AccountProductCatalogSearchModel,
SearchPanel: AccountProductCatalogSearchPanel,
});

View file

@ -0,0 +1,13 @@
import { SearchModel } from "@web/search/search_model";
export class AccountProductCatalogSearchModel extends SearchModel {
setup() {
super.setup(...arguments);
this.selectedSection = {sectionId: null, filtered: false};
}
setSelectedSection(sectionId, filtered) {
this.selectedSection = {sectionId, filtered};
this._notify();
}
}

View file

@ -0,0 +1,172 @@
import { onWillStart, useState } from '@odoo/owl';
import { getActiveHotkey } from '@web/core/hotkeys/hotkey_service';
import { rpc } from '@web/core/network/rpc';
import { useBus } from '@web/core/utils/hooks';
import { SearchPanel } from '@web/search/search_panel/search_panel';
export class AccountProductCatalogSearchPanel extends SearchPanel {
static template = 'account.ProductCatalogSearchPanel';
setup() {
super.setup();
this.state = useState({
...this.state,
sections: new Map(),
isAddingSection: '',
newSectionName: "",
});
useBus(this.env.searchModel, 'section-line-count-change', this.updateSectionLineCount);
onWillStart(async () => await this.loadSections());
}
updateActiveValues() {
super.updateActiveValues();
this.state.sidebarExpanded ||= this.showSections;
}
get showSections() {
return this.env.model.config.context.show_sections;
}
get selectedSection() {
return this.env.searchModel.selectedSection;
}
onDragStart(sectionId, ev) {
ev.dataTransfer.setData('section_id', sectionId);
}
onDragOver(ev) {
ev.preventDefault();
}
onDrop(targetSecId, ev) {
ev.preventDefault();
const moveSecId = parseInt(ev.dataTransfer.getData('section_id'));
if (moveSecId !== targetSecId) this.reorderSections(moveSecId, targetSecId);
}
enableSectionInput(isAddingSection) {
this.state.isAddingSection = isAddingSection;
setTimeout(() => document.querySelector('.o_section_input')?.focus(), 100);
}
onSectionInputKeydown(ev) {
const hotkey = getActiveHotkey(ev);
if (hotkey === 'enter') {
this.createSection();
} else if (hotkey === 'escape') {
Object.assign(this.state, {
isAddingSection: '',
newSectionName: "",
});
}
}
setSelectedSection(sectionId=null, filtered=false) {
this.env.searchModel.setSelectedSection(sectionId, filtered);
}
async createSection() {
const sectionName = this.state.newSectionName.trim();
if (!sectionName) return this.state.isAddingSection = '';
const position = this.state.isAddingSection;
const section = await rpc('/product/catalog/create_section',
this._getSectionInfoParams({
name: sectionName,
position: position,
})
);
if (section) {
const sections = this.state.sections;
let newLineCount = 0;
if (position === 'top') {
newLineCount = sections.get(false).line_count;
sections.delete(false);
}
sections.set(section.id, {
name: this.state.newSectionName,
sequence: section.sequence,
line_count: newLineCount,
});
this._sortSectionsBySequence(sections);
this.setSelectedSection(section.id);
}
Object.assign(this.state, {
isAddingSection: '',
newSectionName: "",
});
}
async loadSections() {
if (!this.showSections) return;
const sections = await rpc('/product/catalog/get_sections', this._getSectionInfoParams());
const sectionMap = new Map();
for (const {id, name, sequence, line_count} of sections) {
sectionMap.set(id, {name, sequence, line_count});
}
this.state.sections = sectionMap;
this.setSelectedSection(sectionMap.size > 0 ? [...sectionMap.keys()][0] : null);
}
async reorderSections(moveId, targetId) {
const sections = this.state.sections;
const moveSection = sections.get(moveId);
const targetSection = sections.get(targetId);
if (!moveSection || !targetSection) return;
const updatedSequences = await rpc('/product/catalog/resequence_sections',
this._getSectionInfoParams({
sections: [
{ id: moveId, sequence: moveSection.sequence },
{ id: targetId, sequence: targetSection.sequence },
],
})
);
for (const [id, sequence] of Object.entries(updatedSequences)) {
const section = sections.get(parseInt(id));
section && (section.sequence = sequence);
}
const noSection = sections.get(false);
noSection && (noSection.sequence = 0); // Reset the sequence of the "No Section"
this._sortSectionsBySequence(sections);
}
updateSectionLineCount({detail: {sectionId, lineCountChange}}) {
const sections = this.state.sections;
const section = sections.get(sectionId);
if (!section) return;
section.line_count = Math.max(0, section.line_count + lineCountChange);
if (section.line_count === 0 && sectionId === false && sections.size > 1) {
sections.delete(sectionId);
this.setSelectedSection(sections.size > 0 ? [...sections.keys()][0] : null);
}
}
_getSectionInfoParams(extra = {}) {
const ctx = this.env.model.config.context;
return {
res_model: ctx.product_catalog_order_model,
order_id: ctx.order_id,
child_field: ctx.child_field,
...extra,
};
}
_sortSectionsBySequence(sections) {
this.state.sections = new Map(
[...sections].sort((a, b) => a[1].sequence - b[1].sequence)
);
}
}

View file

@ -0,0 +1,22 @@
.o_section_input {
border: none;
outline: none;
background: transparent;
border-bottom: 2px solid var(--primary);
}
.o_selected_section {
background-color: var(--list-group-active-bg);
.o_section_name {
font-weight: bold;
}
}
.o_row_handle {
@include o-grab-cursor;
color: #adb5bd;
&:hover {
color: #666666;
}
}

View file

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t
t-name="account.ProductCatalogSearchPanelContent"
t-inherit="web.SearchPanelContent"
t-inherit-mode="primary"
>
<section position="before">
<section t-if="showSections" class="o_search_panel_sections mt-5">
<header class="d-flex align-items-center cursor-default gap-2 mb-3">
<i class="fa fa-filter"/>
<span class="text-uppercase fw-bold">Sections</span>
<div class="d-flex align-items-center ms-auto">
<span class="me-2">Filter</span>
<div class="form-check form-switch mb-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
t-att-checked="selectedSection.filtered ? true : false"
t-on-click="() => this.setSelectedSection(
selectedSection.sectionId, !selectedSection.filtered
)"
/>
</div>
</div>
</header>
<ul class="list-group d-block">
<li
draggable="true"
t-foreach="this.state.sections.keys()"
t-as="sectionId"
t-key="sectionId"
t-att-class="'list-group-item p-0 mb-1 border-0 cursor-pointer ' + (
selectedSection.sectionId == sectionId ? 'o_selected_section' : ''
)"
t-on-dragstart="(e) => onDragStart(sectionId, e)"
t-on-dragover="(e) => onDragOver(e)"
t-on-drop="(e) => this.onDrop(sectionId, e)"
>
<div class="d-flex align-items-center">
<t t-set="section" t-value="this.state.sections.get(sectionId)"/>
<span
t-if="sectionId"
class="o_row_handle oi oi-draggable ui-sortable-handle"
/>
<i
t-else=""
class="fa fa-pencil"
t-on-click="() => this.enableSectionInput('top')"
/>
<input
t-if="sectionId === false and this.state.isAddingSection === 'top'"
class="ms-2 o_section_input"
type="text"
t-model="this.state.newSectionName"
t-on-keydown="onSectionInputKeydown"
t-on-blur="createSection"
/>
<div
t-else=""
class="w-100 ms-2 d-flex align-items-center justify-content-between overflow-hidden"
t-on-click="() => this.setSelectedSection(
sectionId, selectedSection.filtered
)"
t-att-title="section.name"
>
<span class="o_section_name text-truncate" t-out="section.name"/>
<span class="mx-2 text-muted" t-out="section.line_count"/>
</div>
</div>
</li>
</ul>
<input
t-if="this.state.isAddingSection === 'bottom'"
class="o_section_input py-1"
type="text"
placeholder="Enter a description"
t-model="this.state.newSectionName"
t-on-keydown="onSectionInputKeydown"
t-on-blur="createSection"
/>
<button
t-else=""
class="btn btn-link px-0 py-1"
type="button"
t-on-click="() => this.enableSectionInput('bottom')"
>
+ Add Section
</button>
</section>
</section>
<div class="o_search_panel_empty_state me-3" position="attributes">
<attribute name="class" add="d-none" separator=" "/>
</div>
</t>
<t t-name="account.ProductCatalogSearchPanel" t-inherit="web.SearchPanel" t-inherit-mode="primary">
<t t-call="web.SearchPanel.Regular" position="attributes">
<attribute name="t-call">account.ProductCatalogSearchPanelContent</attribute>
</t>
</t>
</templates>

View file

@ -0,0 +1,86 @@
import { _t } from "@web/core/l10n/translation";
import { buildM2OFieldDescription, extractM2OFieldProps, m2oSupportedOptions } from "@web/views/fields/many2one/many2one_field";
import { registry } from "@web/core/registry";
import { ProductNameAndDescriptionField } from "@product/product_name_and_description/product_name_and_description";
export class ProductLabelSectionAndNoteField extends ProductNameAndDescriptionField {
static template = "account.ProductLabelSectionAndNoteField";
static props = {
...super.props,
show_label_warning: { type: Boolean, optional: true, default: false },
};
static descriptionColumn = "name";
get sectionAndNoteClasses() {
return {
"fw-bolder": this.isSection,
"fw-bold": this.isSubSection,
"fst-italic": this.isNote(),
"text-warning": this.shouldShowWarning(),
};
}
get sectionAndNoteIsReadonly() {
return (
this.props.readonly
&& this.isProductClickable
&& (["cancel", "posted"].includes(this.props.record.evalContext.parent.state)
|| this.props.record.evalContext.parent.locked)
)
}
get isSection() {
return this.props.record.data.display_type === "line_section";
}
get isSubSection() {
return this.props.record.data.display_type === "line_subsection";
}
get isSectionOrSubSection() {
return this.isSection || this.isSubSection;
}
isNote(record = null) {
record = record || this.props.record;
return record.data.display_type === "line_note";
}
parseLabel(value) {
return (this.productName && value && this.productName.concat("\n", value))
|| (this.productName && !value && this.productName)
|| (value || "");
}
shouldShowWarning() {
return (
!this.productName &&
this.props.show_label_warning &&
!this.isSectionOrSubSection &&
!this.isNote()
);
}
}
export const productLabelSectionAndNoteField = {
...buildM2OFieldDescription(ProductLabelSectionAndNoteField),
listViewWidth: [240, 400],
supportedOptions: [
...m2oSupportedOptions,
{
label: _t("Show Label Warning"),
name: "show_label_warning",
type: "boolean",
default: false
},
],
extractProps({ options }) {
const props = extractM2OFieldProps(...arguments);
props.show_label_warning = options.show_label_warning;
return props;
},
};
registry
.category("fields")
.add("product_label_section_and_note_field", productLabelSectionAndNoteField);

View file

@ -0,0 +1,14 @@
.o_field_product_label_section_and_note_cell {
textarea {
resize: none;
}
div.o_input {
white-space: pre-wrap;
}
@include media-only(print) {
height: auto !important;
}
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="account.ProductLabelSectionAndNoteField">
<div class="o_field_product_label_section_and_note_cell">
<t t-if="isNote()">
<textarea
class="o_input d-print-none border-0 fst-italic"
placeholder="Enter a description"
rows="1"
t-att-class="sectionAndNoteClasses"
t-att-readonly="sectionAndNoteIsReadonly"
t-att-value="label"
t-ref="labelNodeRef"
t-key="props.readonly"
/>
</t>
<t t-elif="isSectionOrSubSection">
<input
type="text"
class="o_input text-wrap border-0 w-100"
placeholder="Enter a description"
t-att-class="sectionAndNoteClasses"
t-att-readonly="sectionAndNoteIsReadonly"
t-att-value="label"
t-ref="labelNodeRef"
t-key="props.readonly"
/>
</t>
<t t-else="">
<div class="d-flex align-items-center gap-1">
<Many2One t-props="this.m2oProps" cssClass="'w-100'" t-on-keydown="onM2oInputKeydown"/>
<t t-if="showLabelVisibilityToggler">
<button
class="btn fa fa-bars text-start o_external_button px-1"
type="button"
id="labelVisibilityButtonId"
data-tooltip="Click or press enter to add a description"
t-on-click="() => this.switchLabelVisibility()"
/>
</t>
</div>
<div
t-if="props.readonly"
class="o_input d-print-none border-0 fst-italic"
t-att-class="{ ...sectionAndNoteClasses, 'd-none': !(columnIsProductAndLabel.value and label) }"
t-out="label"
/>
<textarea
t-else=""
class="o_input d-print-none border-0 fst-italic"
placeholder="Enter a description"
rows="1"
type="text"
t-att-class="{ ...sectionAndNoteClasses, 'd-none': !(columnIsProductAndLabel.value and (label or labelVisibility.value)) }"
t-att-value="label"
t-ref="labelNodeRef"
/>
</t>
<t t-if="isPrintMode.value">
<div class="d-none d-print-block text-wrap" t-out="label"/>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,75 @@
import {
SectionAndNoteFieldOne2Many,
sectionAndNoteFieldOne2Many,
SectionAndNoteListRenderer,
} from "@account/components/section_and_note_fields_backend/section_and_note_fields_backend";
import { ProductNameAndDescriptionListRendererMixin } from "@product/product_name_and_description/product_name_and_description";
import { registry } from "@web/core/registry";
import { patch } from "@web/core/utils/patch";
export class ProductLabelSectionAndNoteListRender extends SectionAndNoteListRenderer {
setup() {
super.setup();
this.descriptionColumn = "name";
this.productColumns = ["product_id", "product_template_id"];
this.conditionalColumns = ["product_id", "quantity", "product_uom_id"];
}
processAllColumn(allColumns, list) {
allColumns = allColumns.map((column) => {
if (column["optional"] === "conditional" && this.conditionalColumns.includes(column["name"])) {
/**
* The preference should be different whether:
* - It's a Vendor Bill or an Invoice
* - Sale module is installed
* Vendor Bills -> Product should be hidden by default
* Invoices -> conditionalColumns should be hidden by default if Sale module is not installed
*/
const isBill = ["in_invoice", "in_refund", "in_receipt"].includes(this.props.list.evalContext.parent.move_type);
const isInvoice = ["out_invoice", "out_refund", "out_receipt"].includes(this.props.list.evalContext.parent.move_type);
const isSaleInstalled = this.props.list.evalContext.parent.is_sale_installed;
column["optional"] = "show";
if (isBill && column["name"] === "product_id") {
column["optional"] = "hide";
}
else if (isInvoice && !isSaleInstalled) {
column["optional"] = "hide";
}
}
return column;
});
return super.processAllColumn(allColumns, list);
}
isCellReadonly(column, record) {
if (![...this.productColumns, "name"].includes(column.name)) {
return super.isCellReadonly(column, record);
}
// The isCellReadonly method from the ListRenderer is used to determine the classes to apply to the cell.
// We need this override to make sure some readonly classes are not applied to the cell if it is still editable.
let isReadonly = super.isCellReadonly(column, record);
return (
isReadonly
&& (["cancel", "posted"].includes(record.evalContext.parent.state)
|| record.evalContext.parent.locked)
)
}
}
patch(ProductLabelSectionAndNoteListRender.prototype, ProductNameAndDescriptionListRendererMixin);
export class ProductLabelSectionAndNoteOne2Many extends SectionAndNoteFieldOne2Many {
static components = {
...super.components,
ListRenderer: ProductLabelSectionAndNoteListRender,
};
}
export const productLabelSectionAndNoteOne2Many = {
...sectionAndNoteFieldOne2Many,
component: ProductLabelSectionAndNoteOne2Many,
};
registry
.category("fields")
.add("product_label_section_and_note_field_o2m", productLabelSectionAndNoteOne2Many);

View file

@ -0,0 +1,93 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { radioField, RadioField } from "@web/views/fields/radio/radio_field";
import { onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { deepCopy } from "@web/core/utils/objects";
const labels = {
'in_invoice': _t("Bill"),
'out_invoice': _t("Invoice"),
'in_receipt': _t("Receipt"),
'out_receipt': _t("Receipt"),
};
const in_move_types = ['in_invoice', 'in_receipt']
const out_move_types = ['out_invoice', 'out_receipt']
export class ReceiptSelector extends RadioField {
static template = "account.ReceiptSelector";
static props = {
...RadioField.props,
};
setup() {
super.setup();
this.lazySession = useService("lazy_session");
this.show_sale_receipts = useState({ value: false });
onWillStart(()=> {
this.lazySession.getValue("show_sale_receipts", (show_sale_receipts) => {
this.show_sale_receipts.value = show_sale_receipts;
})
});
}
/**
* Remove the unwanted options and update the English labels
* @override
*/
get items() {
const original_items = super.items;
if ( this.type !== 'selection' ) {
return original_items;
}
// Use a copy to avoid updating the original selection labels
let items = deepCopy(original_items)
let allowedValues = [];
if ( in_move_types.includes(this.value) ) {
allowedValues = in_move_types
} else if (out_move_types.includes(this.value) && this.show_sale_receipts.value ) {
allowedValues = out_move_types
}
if ( allowedValues.length > 1 ) {
// Filter only the wanted items
items = items.filter((item) => {
return (allowedValues.includes(item[0]));
});
// Update the label of the wanted items
items.forEach((item) => {
if (item[0] in labels) {
item[1] = labels[item[0]];
}
});
}
return items;
}
get string() {
if ( this.type === 'selection' ) {
// Use the original labels and not the modified ones
return this.value !== false
? this.props.record.fields[this.props.name].selection.find((i) => i[0] === this.value)[1]
: "";
}
return "";
}
}
export const receiptSelector = {
...radioField,
additionalClasses: ['o_field_radio'],
component: ReceiptSelector,
extractProps() {
return radioField.extractProps(...arguments);
},
};
registry.category("fields").add("receipt_selector", receiptSelector);

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.ReceiptSelector">
<div>
<t t-if="props.readonly || ['in_refund', 'out_refund'].includes(value) || (!show_sale_receipts.value and ['out_invoice', 'out_receipt'].includes(value))">
<span t-esc="string" t-att-raw-value="value" />
</t>
<t t-else="">
<t t-call="web.RadioField"/>
</t>
</div>
</t>
</templates>

View file

@ -2,29 +2,76 @@
// The goal of this file is to contain CSS hacks related to allowing
// section and note on sale order and invoice.
table.o_section_and_note_list_view tr.o_data_row.o_is_line_note,
table.o_section_and_note_list_view tr.o_data_row.o_is_line_note textarea[name="name"],
div.oe_kanban_card.o_is_line_note {
font-style: italic;
}
table.o_section_and_note_list_view tr.o_data_row.o_is_line_section,
div.oe_kanban_card.o_is_line_section {
font-weight: bold;
background-color: #DDDDDD;
}
table.o_section_and_note_list_view tr.o_data_row.o_is_line_section {
border-top: 1px solid #BBB;
border-bottom: 1px solid #BBB;
}
table.o_section_and_note_list_view {
--o-SectionAndNote-border-color: #{$gray-500};
$_SectionAndNote-transition: 0.1s ease-in-out;
table.o_section_and_note_list_view tr.o_data_row {
&.o_is_line_note,
&.o_is_line_section {
td {
// There is an undeterministic CSS behaviour in Chrome related to
// the combination of the row's and its children's borders.
border: none !important;
tr {
&.o_is_line_note {
font-style: italic;
}
&.o_is_line_section, &.o_is_line_subsection {
--table-striped-bg: var(--table-bg-type);
.o_list_section_options {
width: 1px; // to prevent the column to expand
}
.o_field_product_label_section_and_note_cell .o_input {
text-overflow: ellipsis;
}
}
&.o_is_line_section {
--table-hover-bg: #{rgba($body-emphasis-color, .08)};
--table-bg-type: #{rgba($body-emphasis-color, .06)};
--ListRenderer-data-row-focused-striped-bg: var(--table-bg-type);
font-weight: $font-weight-bolder;
&:where(:not(:empty)) {
// When dragging, an empty tr is created we don't want
// a border here.
border-top-color: var(--o-SectionAndNote-border-color);
}
&.o_dragged {
border-width: $border-width 0;
}
}
&.o_is_line_subsection {
--table-hover-bg: #{rgba($body-emphasis-color, .05)};
--table-bg-type: #{rgba($body-emphasis-color, .03)};
font-weight: $font-weight-bold;
&.o_dragged {
// We rely on td borders, when dragged we need to remove
// the borders on .o_data_row to avoid double borders
--ListRenderer-data-row-border-bottom-width: 0;
}
td {
border-bottom-width: $border-width;
&:not(.o_handle_cell) {
border-color: var(--o-SectionAndNote-border-color);
}
}
}
// Check if next sibling is a section to apply a border-color.
&:where(:not(.o_dragged):has(+ .o_is_line_section:not(.o_dragged, .d-table-row))) {
border-color: var(--o-SectionAndNote-border-color);
}
}
// Check if first tr in the table is a section to apply a border-color on
// the <th> els.
&:where(:has(tbody > tr:first-child.o_is_line_section:not(.o_dragged, .d-table-row))) th {
border-color: var(--o-SectionAndNote-border-color);
}
}

View file

@ -1,14 +1,88 @@
/** @odoo-module **/
import { Component, useEffect } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { x2ManyCommands } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { ListRenderer } from "@web/views/list/list_renderer";
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
import { TextField, ListTextField } from "@web/views/fields/text/text_field";
import { CharField } from "@web/views/fields/char/char_field";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { ListTextField, TextField } from "@web/views/fields/text/text_field";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { ListRenderer } from "@web/views/list/list_renderer";
const { Component, useEffect } = owl;
const SHOW_ALL_ITEMS_TOOLTIP = _t("Some lines can be on the next page, display them to unlock actions on section.");
const DISABLED_MOVE_DOWN_ITEM_TOOLTIP = _t("Some lines of the next section can be on the next page, display them to unlock the action.");
const DISPLAY_TYPES = {
NOTE: "line_note",
SECTION: "line_section",
SUBSECTION: "line_subsection",
};
export function getParentSectionRecord(list, record) {
const { sectionIndex } = getRecordsUntilSection(list, record, false, record.data.display_type !== DISPLAY_TYPES.SUBSECTION);
return list.records[sectionIndex];
}
function getPreviousSectionRecords(list, record) {
const { sectionRecords } = getRecordsUntilSection(list, record, false);
return sectionRecords;
}
export function getSectionRecords(list, record, subSection) {
const { sectionRecords } = getRecordsUntilSection(list, record, true, subSection);
return sectionRecords;
}
function hasNextSection(list, record) {
const { sectionIndex } = getRecordsUntilSection(list, record, true);
return sectionIndex < list.records.length && list.records[sectionIndex].data.display_type === record.data.display_type;
}
function hasPreviousSection(list, record) {
const { sectionIndex } = getRecordsUntilSection(list, record, false);
return sectionIndex >= 0 && list.records[sectionIndex].data.display_type === record.data.display_type;
}
function getRecordsUntilSection(list, record, asc, subSection) {
const stopAtTypes = [DISPLAY_TYPES.SECTION];
if (subSection ?? record.data.display_type === DISPLAY_TYPES.SUBSECTION) {
stopAtTypes.push(DISPLAY_TYPES.SUBSECTION);
}
const sectionRecords = [];
let index = list.records.findIndex(listRecord => listRecord.id === record.id);
if (asc) {
sectionRecords.push(list.records[index]);
index++;
while (index < list.records.length && !stopAtTypes.includes(list.records[index].data.display_type)) {
sectionRecords.push(list.records[index]);
index++;
}
} else {
index--;
while (index >= 0 && !stopAtTypes.includes(list.records[index].data.display_type)) {
sectionRecords.unshift(list.records[index]);
index--;
}
sectionRecords.unshift(list.records[index]);
}
return {
sectionRecords,
sectionIndex: index,
};
}
export class SectionAndNoteListRenderer extends ListRenderer {
static template = "account.SectionAndNoteListRenderer";
static recordRowTemplate = "account.SectionAndNoteListRenderer.RecordRow";
static props = [
...super.props,
"aggregatedFields",
"subsections",
"hidePrices",
"hideComposition",
];
/**
* The purpose of this extension is to allow sections and notes in the one2many list
* primarily used on Sales Orders and Invoices
@ -18,47 +92,335 @@ export class SectionAndNoteListRenderer extends ListRenderer {
setup() {
super.setup();
this.titleField = "name";
this.priceColumns = [...this.props.aggregatedFields, "price_unit"];
// invisible fields to force copy when duplicating a section
this.copyFields = ["display_type", "collapse_composition", "collapse_prices"];
useEffect(
() => this.focusToName(this.props.list.editedRecord),
() => [this.props.list.editedRecord]
)
(editedRecord) => this.focusToName(editedRecord),
() => [this.editedRecord]
);
}
get disabledMoveDownItemTooltip() {
return DISABLED_MOVE_DOWN_ITEM_TOOLTIP;
}
get showAllItemsTooltip() {
return SHOW_ALL_ITEMS_TOOLTIP;
}
get hidePrices() {
return this.record.data.collapse_prices;
}
get hideComposition() {
return this.record.data.collapse_composition;
}
get disablePricesButton() {
return this.shouldCollapse(this.record, 'collapse_prices') || this.disableCompositionButton;
}
get disableCompositionButton() {
return this.shouldCollapse(this.record, 'collapse_composition');
}
async toggleCollapse(record, fieldName) {
// We don't want to have 'collapse_prices' & 'collapse_composition' set to True at the same time
const reverseFieldName = fieldName === 'collapse_prices' ? 'collapse_composition' : 'collapse_prices';
const changes = {
[fieldName]: !record.data[fieldName],
[reverseFieldName]: false,
};
await record.update(changes);
}
async addRowAfterSection(record, addSubSection) {
const canProceed = await this.props.list.leaveEditMode({ canAbandon: false });
if (!canProceed) {
return;
}
const index =
this.props.list.records.indexOf(record) +
getSectionRecords(this.props.list, record).length -
1;
const context = {
default_display_type: addSubSection ? DISPLAY_TYPES.SUBSECTION : DISPLAY_TYPES.SECTION,
};
await this.props.list.addNewRecordAtIndex(index, { context });
}
async addNoteInSection(record) {
const canProceed = await this.props.list.leaveEditMode({ canAbandon: false });
if (!canProceed) {
return;
}
const index =
this.props.list.records.indexOf(record) +
getSectionRecords(this.props.list, record, true).length -
1;
const context = {
default_display_type: DISPLAY_TYPES.NOTE,
};
await this.props.list.addNewRecordAtIndex(index, { context });
}
async addRowInSection(record, addSubSection) {
const canProceed = await this.props.list.leaveEditMode({ canAbandon: false });
if (!canProceed) {
return;
}
const index =
this.props.list.records.indexOf(record) +
getSectionRecords(this.props.list, record, !addSubSection).length -
1;
const context = this.getInsertLineContext(record, addSubSection);
if (addSubSection) {
context["default_display_type"] = DISPLAY_TYPES.SUBSECTION;
}
await this.props.list.addNewRecordAtIndex(index, { context });
}
/**
* Hook for other modules to conditionally specify defaults for new lines
*/
getInsertLineContext(_record, _addSubSection) {
return {};
}
canUseFormatter(column, record) {
if (
this.isSection(record) &&
this.props.aggregatedFields.includes(column.name)
) {
return true;
}
return super.canUseFormatter(column, record);
}
async deleteSection(record) {
if (this.editedRecord && this.editedRecord !== record) {
const left = await this.props.list.leaveEditMode({ canAbandon: false });
if (!left) {
return;
}
}
if (this.activeActions.onDelete) {
const method = this.activeActions.unlink ? "unlink" : "delete";
const commands = [];
const sectionRecords = getSectionRecords(this.props.list, record);
for (const sectionRecord of sectionRecords) {
commands.push(
x2ManyCommands[method](sectionRecord.resId || sectionRecord._virtualId)
);
}
await this.props.list.applyCommands(commands);
}
}
async duplicateSection(record) {
const left = await this.props.list.leaveEditMode();
if (!left) {
return;
}
const { sectionRecords, sectionIndex } = getRecordsUntilSection(this.props.list, record, true)
const recordsToDuplicate = sectionRecords.filter((record) => {
return this.shouldDuplicateSectionItem(record);
});
await this.props.list.duplicateRecords(recordsToDuplicate, {
targetIndex: sectionIndex,
copyFields: this.copyFields,
});
}
async editNextRecord(record, group) {
const canProceed = await this.props.list.leaveEditMode({ validate: true });
if (!canProceed) {
return;
}
const iter = getRecordsUntilSection(this.props.list, record, true, true);
if (this.isSection(record) || iter.sectionRecords.length === 1) {
return this.props.list.addNewRecordAtIndex(iter.sectionIndex - 1);
} else {
return super.editNextRecord(record, group);
}
}
expandPager() {
return this.props.list.load({ limit: this.props.list.count });
}
focusToName(editRec) {
if (editRec && editRec.isVirtual && this.isSectionOrNote(editRec)) {
const col = this.state.columns.find((c) => c.name === this.titleField);
if (editRec && editRec.isNew && this.isSectionOrNote(editRec)) {
const col = this.columns.find((c) => c.name === this.titleField);
this.focusCell(col, null);
}
}
isSectionOrNote(record=null) {
hasNextSection(record) {
return hasNextSection(this.props.list, record);
}
hasPreviousSection(record) {
return hasPreviousSection(this.props.list, record);
}
isNextSectionInPage(record) {
if (this.props.list.count <= this.props.list.offset + this.props.list.limit) {
// if last page
return true;
}
const sectionRecords = getSectionRecords(this.props.list, record);
const index = this.props.list.records.indexOf(record) + sectionRecords.length;
if (index >= this.props.list.limit) {
return false;
}
const { sectionIndex } = getRecordsUntilSection(this.props.list, this.props.list.records[index], true);
return sectionIndex < this.props.list.limit;
}
isSectionOrNote(record = null) {
record = record || this.record;
return ['line_section', 'line_note'].includes(record.data.display_type);
return [DISPLAY_TYPES.SECTION, DISPLAY_TYPES.SUBSECTION, DISPLAY_TYPES.NOTE].includes(
record.data.display_type
);
}
isSection(record = null) {
record = record || this.record;
return [DISPLAY_TYPES.SECTION, DISPLAY_TYPES.SUBSECTION].includes(record.data.display_type);
}
isSectionInPage(record) {
if (this.props.list.count <= this.props.list.offset + this.props.list.limit) {
// if last page
return true;
}
const { sectionIndex } = getRecordsUntilSection(this.props.list, record, true);
return sectionIndex < this.props.list.limit;
}
isSortable() {
return false;
}
isTopSection(record) {
return record.data.display_type === DISPLAY_TYPES.SECTION;
}
isSubSection(record) {
return record.data.display_type === DISPLAY_TYPES.SUBSECTION;
}
/**
* Determines whether the line should be collapsed.
* - If the parent is a section: use the parents field.
* - If the parent is a subsection: use parent subsection OR its section.
* @param {object} record
* @param {string} fieldName
* @param {boolean} checkSection - if true, also evaluates the collapse state for section or
* subsection records
* @returns {boolean}
*/
shouldCollapse(record, fieldName, checkSection = false) {
const parentSection = getParentSectionRecord(this.props.list, record);
// --- For sections ---
if (this.isSection(record) && checkSection) {
if (this.isTopSection(record)) {
return record.data[fieldName];
}
if (this.isSubSection(record)) {
return record.data[fieldName] || parentSection?.data[fieldName];
}
return false;
}
// `line_section` never collapses unless explicitly checked above
if (this.isTopSection(record)) {
return false;
}
if (!parentSection) {
return false;
}
// --- For regular lines ---
if (this.isSubSection(parentSection)) {
const grandParent = getParentSectionRecord(this.props.list, parentSection);
return parentSection.data[fieldName] || grandParent?.data[fieldName];
}
return !!parentSection.data[fieldName];
}
getRowClass(record) {
const existingClasses = super.getRowClass(record);
return `${existingClasses} o_is_${record.data.display_type}`;
let newClasses = `${existingClasses} o_is_${record.data.display_type}`;
if (this.props.hideComposition && this.shouldCollapse(record, 'collapse_composition')) {
newClasses += " text-muted";
}
return newClasses;
}
getCellClass(column, record) {
const classNames = super.getCellClass(column, record);
if (this.isSectionOrNote(record) && column.widget !== "handle" && column.name !== this.titleField) {
let classNames = super.getCellClass(column, record);
// For hiding columnns of section and note
if (
this.isSectionOrNote(record)
&& column.widget !== "handle"
&& ![column.name, ...this.props.aggregatedFields].includes(column.name)
) {
return `${classNames} o_hidden`;
}
// For muting the price columns
if (
this.props.hidePrices
&& this.shouldCollapse(record, 'collapse_prices')
&& this.priceColumns.includes(column.name)
) {
classNames += " text-muted";
}
return classNames;
}
getColumns(record) {
const columns = super.getColumns(record);
if (this.isSectionOrNote(record)) {
return this.getSectionColumns(columns);
return this.getSectionColumns(columns, record);
}
return columns;
}
getSectionColumns(columns) {
const sectionCols = columns.filter((col) => col.widget === "handle" || col.type === "field" && col.name === this.titleField);
getFormattedValue(column, record) {
if (this.isSection(record) && this.props.aggregatedFields.includes(column.name)) {
const total = getSectionRecords(this.props.list, record)
.filter((record) => !this.isSection(record))
.reduce((total, record) => total + record.data[column.name], 0);
const formatter = registry.category("formatters").get(column.fieldType, (val) => val);
return formatter(total, {
...formatter.extractOptions?.(column),
data: record.data,
field: record.fields[column.name],
});
}
return super.getFormattedValue(column, record);
}
getSectionColumns(columns, record) {
const sectionCols = columns.filter(
(col) =>
col.widget === "handle"
|| col.name === this.titleField
|| (this.isSection(record) && this.props.aggregatedFields.includes(col.name))
);
return sectionCols.map((col) => {
if (col.name === this.titleField) {
return { ...col, colspan: columns.length - sectionCols.length + 1 };
@ -67,23 +429,119 @@ export class SectionAndNoteListRenderer extends ListRenderer {
}
});
}
}
SectionAndNoteListRenderer.template = "account.sectionAndNoteListRenderer";
export class SectionAndNoteFieldOne2Many extends X2ManyField {}
SectionAndNoteFieldOne2Many.additionalClasses = ['o_field_one2many'];
SectionAndNoteFieldOne2Many.components = {
...X2ManyField.components,
ListRenderer: SectionAndNoteListRenderer,
};
async moveSectionDown(record) {
const canProceed = await this.props.list.leaveEditMode({ canAbandon: false });
if (!canProceed) {
return;
}
export class SectionAndNoteText extends Component {
get componentToUse() {
return this.props.record.data.display_type === 'line_section' ? CharField : TextField;
const sectionRecords = getSectionRecords(this.props.list, record);
const index = this.props.list.records.indexOf(record) + sectionRecords.length;
const nextSectionRecords = getSectionRecords(this.props.list, this.props.list.records[index]);
return this.swapSections(sectionRecords, nextSectionRecords);
}
async moveSectionUp(record) {
const canProceed = await this.props.list.leaveEditMode({ canAbandon: false });
if (!canProceed) {
return;
}
const previousSectionRecords = getPreviousSectionRecords(this.props.list, record);
const sectionRecords = getSectionRecords(this.props.list, record);
return this.swapSections(previousSectionRecords, sectionRecords);
}
shouldDuplicateSectionItem(record) {
return true;
}
async swapSections(sectionRecords1, sectionRecords2) {
const commands = [];
let sequence = sectionRecords1[0].data[this.props.list.handleField];
for (const record of sectionRecords2) {
commands.push(x2ManyCommands.update(record.resId || record._virtualId, {
[this.props.list.handleField]: sequence++,
}));
}
for (const record of sectionRecords1) {
commands.push(x2ManyCommands.update(record.resId || record._virtualId, {
[this.props.list.handleField]: sequence++,
}));
}
await this.props.list.applyCommands(commands, { sort: true });
}
/**
* @override
* Reset the values of `collapse_` fields of the subsection if it is dragged
*/
async sortDrop(dataRowId, dataGroupId, options) {
await super.sortDrop(dataRowId, dataGroupId, options);
const record = this.props.list.records.find(r => r.id === dataRowId);
const parentSection = getParentSectionRecord(this.props.list, record);
const commands = [];
if (this.resetOnResequence(record, parentSection)) {
commands.push(x2ManyCommands.update(record.resId || record._virtualId, {
...this.fieldsToReset(),
}));
}
await this.props.list.applyCommands(commands);
}
resetOnResequence(record, parentSection) {
return (
this.isSubSection(record)
&& parentSection?.data.collapse_composition
&& (record.data.collapse_composition || record.data.collapse_prices)
);
}
fieldsToReset() {
return {
...(this.props.hideComposition && { collapse_composition: false }),
...(this.props.hidePrices && { collapse_prices: false }),
};
}
}
export class SectionAndNoteFieldOne2Many extends X2ManyField {
static components = {
...super.components,
ListRenderer: SectionAndNoteListRenderer,
};
static props = {
...super.props,
aggregatedFields: Array,
hideComposition: Boolean,
hidePrices: Boolean,
subsections: Boolean,
};
get rendererProps() {
const rp = super.rendererProps;
if (this.props.viewMode === "list") {
rp.aggregatedFields = this.props.aggregatedFields;
rp.hideComposition = this.props.hideComposition;
rp.hidePrices = this.props.hidePrices;
rp.subsections = this.props.subsections;
}
return rp;
}
}
export class SectionAndNoteText extends Component {
static template = "account.SectionAndNoteText";
static props = { ...standardFieldProps };
get componentToUse() {
return this.props.record.data.display_type === "line_section" ? CharField : TextField;
}
}
SectionAndNoteText.template = "account.SectionAndNoteText";
SectionAndNoteText.additionalClasses = ["o_field_text"];
export class ListSectionAndNoteText extends SectionAndNoteText {
get componentToUse() {
@ -93,6 +551,33 @@ export class ListSectionAndNoteText extends SectionAndNoteText {
}
}
registry.category("fields").add("section_and_note_one2many", SectionAndNoteFieldOne2Many);
registry.category("fields").add("section_and_note_text", SectionAndNoteText);
registry.category("fields").add("list.section_and_note_text", ListSectionAndNoteText);
export const sectionAndNoteFieldOne2Many = {
...x2ManyField,
component: SectionAndNoteFieldOne2Many,
additionalClasses: [...(x2ManyField.additionalClasses || []), "o_field_one2many"],
extractProps: (staticInfo, dynamicInfo) => {
return {
...x2ManyField.extractProps(staticInfo, dynamicInfo),
aggregatedFields: staticInfo.attrs.aggregated_fields
? staticInfo.attrs.aggregated_fields.split(/\s*,\s*/)
: [],
hideComposition: staticInfo.options?.hide_composition ?? false,
hidePrices: staticInfo.options?.hide_prices ?? false,
subsections: staticInfo.options?.subsections ?? false,
};
},
};
export const sectionAndNoteText = {
component: SectionAndNoteText,
additionalClasses: ["o_field_text"],
};
export const listSectionAndNoteText = {
...sectionAndNoteText,
component: ListSectionAndNoteText,
};
registry.category("fields").add("section_and_note_one2many", sectionAndNoteFieldOne2Many);
registry.category("fields").add("section_and_note_text", sectionAndNoteText);
registry.category("fields").add("list.section_and_note_text", listSectionAndNoteText);

View file

@ -1,12 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.sectionAndNoteListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary" owl="1">
<t t-name="account.SectionAndNoteListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//table" position="attributes">
<attribute name="class" position="add" separator=" ">o_section_and_note_list_view</attribute>
<attribute name="class" add="o_section_and_note_list_view" separator=" "/>
</xpath>
</t>
<t t-name="account.SectionAndNoteListRenderer.RecordRow" t-inherit="web.ListRenderer.RecordRow">
<xpath expr="//td[hasclass('o_list_record_remove')]" position="attributes">
<attribute name="t-if">!isSection(record)</attribute>
</xpath>
<xpath expr="//td[hasclass('o_list_record_remove')]" position="after">
<td t-else="" class="o_list_section_options w-print-0 p-print-0 text-center">
<Dropdown position="'bottom-end'" t-if="!props.readonly">
<button class="btn d-table-cell border-0 py-0 px-1 cursor-pointer">
<i class="fa fa-ellipsis-v"/>
</button>
<t t-set-slot="content">
<t t-if="this.isSectionInPage(record)">
<DropdownItem onSelected="() => this.addRowInSection(record, false)">
<i class="me-1 fa fa-fw fa-plus"/><span>Add a line</span>
</DropdownItem>
<t t-if="this.isTopSection(record)">
<t t-if="props.subsections">
<DropdownItem onSelected="() => this.addRowInSection(record, true)">
<i class="me-1 fa fa-fw fa-level-down"/><span>Add a subsection</span>
</DropdownItem>
</t>
</t>
<DropdownItem
t-if="props.hidePrices"
onSelected="() => this.toggleCollapse(record, 'collapse_prices')"
attrs="{ 'class': disablePricesButton ? 'disabled' : '' }"
>
<i class="me-1 fa fa-fw" t-att-class="hidePrices ? 'fa-eye' : 'fa-eye-slash'"/>
<span t-if="hidePrices">Show Prices</span>
<span t-else="">Hide Prices</span>
</DropdownItem>
<t t-name="composition_button">
<DropdownItem
t-if="props.hideComposition"
onSelected="() => this.toggleCollapse(record, 'collapse_composition')"
attrs="{ 'class': disableCompositionButton ? 'disabled' : '' }"
>
<i class="me-1 fa fa-fw" t-att-class="hideComposition ? 'fa-eye' : 'fa-eye-slash'"/>
<span t-if="hideComposition">Show Composition</span>
<span t-else="">Hide Composition</span>
</DropdownItem>
</t>
<DropdownItem onSelected="() => this.addNoteInSection(record)">
<i class="me-1 fa fa-fw fa-sticky-note-o"/><span>Add a note</span>
</DropdownItem>
<DropdownItem t-if="this.hasPreviousSection(record)" onSelected="() => this.moveSectionUp(record)">
<i class="me-1 fa fa-fw fa-arrow-up"/><span>Move Up</span>
</DropdownItem>
<t t-set="nextSectionInPage" t-value="this.isNextSectionInPage(record)"/>
<t t-set="moveDownItemAttrs" t-value="nextSectionInPage ? {} : { 'data-tooltip': this.disabledMoveDownItemTooltip }"/>
<t t-set="moveDownItemDefaultProps" t-value="{ attrs: moveDownItemAttrs, class: { 'text-muted': !nextSectionInPage } }"/>
<DropdownItem t-if="this.hasNextSection(record)" t-props="moveDownItemDefaultProps" onSelected="() => this.moveSectionDown(record)">
<i class="me-1 fa fa-fw fa-arrow-down"/><span>Move Down</span>
</DropdownItem>
<DropdownItem onSelected="() => this.duplicateSection(record)">
<i class="me-1 fa fa-fw fa-clone"/><span>Duplicate</span>
</DropdownItem>
<t t-if="hasDeleteButton">
<DropdownItem onSelected="() => this.deleteSection(record)" class="'text-danger'">
<i class="me-1 fa fa-fw fa-trash"/><span>Delete</span>
</DropdownItem>
</t>
</t>
<t t-else="">
<DropdownItem onSelected="() => this.expandPager()" attrs="{ 'data-tooltip': this.showAllItemsTooltip }">
<i class="me-1 fa fa-fw fa-expand"/><span>Show all lines</span>
</DropdownItem>
</t>
</t>
</Dropdown>
</td>
</xpath>
</t>
<t t-name="account.SectionAndNoteText" owl="1">
<t t-name="account.SectionAndNoteText">
<t t-component="componentToUse" t-props="props"/>
</t>

View file

@ -1,33 +0,0 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { ResConfigDevTool } from "@web/webclient/settings_form_view/widgets/res_config_dev_tool";
/**
* Override of the widget in the settings that handles the "Developer Tools" section.
* Provides a button to download XSD files for XML validation.
*/
class ResConfigDevToolDownloadXsd extends ResConfigDevTool {
/**
* Downloads every XSD file, based on installed localisations.
*/
setup() {
super.setup();
this.rpc = useService("rpc");
}
async onClickDownloadXSD() {
await this.rpc("/web/dataset/call_kw/ir.attachment/action_download_xsd_files", {
model: 'ir.attachment',
method: 'action_download_xsd_files',
args: [],
kwargs: {}
})
}
}
ResConfigDevToolDownloadXsd.template = "res_config_dev_tool";
registry.category("view_widgets").add("res_config_dev_tool", ResConfigDevToolDownloadXsd, {force: true});

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<template>
<div t-name='account.res_config_dev_tool' t-inherit="web.res_config_dev_tool" t-inherit-mode="extension" owl="1" primary="1">
<xpath expr="//Setting/div" position="inside">
<a t-if="isDebug" class="d-block" t-on-click.prevent="onClickDownloadXSD" href="#">Download XSD files (XML validation)</a>
</xpath>
</div>
</template>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.TaxAutoComplete" t-inherit="web.AutoComplete">
<xpath expr="//t[@t-out='option.label']" position="replace">
<t t-if="option.data.record.tax_scope">
<div class="tax_autocomplete_grid">
<div t-out="option.label"/>
<div t-esc="option.data.record.tax_scope" class="text-muted"/>
</div>
</t>
<t t-else="">
<span t-out="option.label"/>
</t>
</xpath>
</t>
</templates>

View file

@ -1,25 +1,40 @@
/** @odoo-module **/
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
import { formatMonetary } from "@web/views/fields/formatters";
import { formatFloat } from "@web/core/utils/numbers";
import { parseFloat } from "@web/views/fields/parsers";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import {
Component,
onPatched,
onWillUpdateProps,
onWillRender,
toRaw,
useRef,
useState,
} from "@odoo/owl";
import { useNumpadDecimal } from "@web/views/fields/numpad_decimal_hook";
const { Component, onPatched, onWillUpdateProps, useRef, useState } = owl;
/**
A line of some TaxTotalsComponent, giving the values of a tax group.
**/
class TaxGroupComponent extends Component {
static props = {
totals: { optional: true },
subtotal: { optional: true },
taxGroup: { optional: true },
onChangeTaxGroup: { optional: true },
isReadonly: Boolean,
invalidate: Function,
};
static template = "account.TaxGroupComponent";
setup() {
this.inputTax = useRef("taxValueInput");
this.state = useState({ value: "readonly" });
onPatched(() => {
if (this.state.value === "edit") {
const { taxGroup, currency } = this.props;
const newVal = formatFloat(taxGroup.tax_group_amount, { digits: (currency && currency.digits) });
const { taxGroup } = this.props;
const newVal = formatFloat(taxGroup.tax_amount_currency, { digits: this.props.totals.currency_pd });
this.inputTax.el.value = newVal;
this.inputTax.el.focus(); // Focus the input
}
@ -30,6 +45,10 @@ class TaxGroupComponent extends Component {
useNumpadDecimal();
}
formatMonetary(value) {
return formatMonetary(value, {currencyId: this.props.totals.currency_id});
}
//--------------------------------------------------------------------------
// Main methods
//--------------------------------------------------------------------------
@ -66,11 +85,11 @@ class TaxGroupComponent extends Component {
*/
_onChangeTaxValue() {
this.setState("disable"); // Disable the input
const oldValue = this.props.taxGroup.tax_group_amount;
const oldValue = this.props.taxGroup.tax_amount_currency;
let newValue;
try {
newValue = parseFloat(this.inputTax.el.value); // Get the new value
} catch (_err) {
} catch {
this.inputTax.el.value = oldValue;
this.setState("edit");
return;
@ -80,25 +99,20 @@ class TaxGroupComponent extends Component {
this.setState("readonly");
return;
}
this.props.taxGroup.tax_group_amount = newValue;
const deltaValue = newValue - oldValue;
this.props.taxGroup.tax_amount_currency += deltaValue;
this.props.subtotal.tax_amount_currency += deltaValue;
this.props.totals.tax_amount_currency += deltaValue;
this.props.totals.total_amount_currency += deltaValue;
this.props.onChangeTaxGroup({
oldValue,
newValue: newValue,
taxGroupId: this.props.taxGroup.tax_group_id,
taxGroupId: this.props.taxGroup.id,
});
}
}
TaxGroupComponent.props = {
currency: { optional: true },
taxGroup: { optional: true },
onChangeTaxGroup: { optional: true },
isReadonly: Boolean,
invalidate: Function,
};
TaxGroupComponent.template = "account.TaxGroupComponent";
/**
Widget used to display tax totals by tax groups for invoices, PO and SO,
and possibly allowing editing them.
@ -107,31 +121,30 @@ TaxGroupComponent.template = "account.TaxGroupComponent";
currency_id field.
**/
export class TaxTotalsComponent extends Component {
static template = "account.TaxTotalsField";
static components = { TaxGroupComponent };
static props = {
...standardFieldProps,
};
setup() {
this.totals = {};
this.formatData(this.props);
onWillUpdateProps((nextProps) => {
this.formatData(nextProps);
});
onWillRender(() => this.formatData(this.props));
}
get readonly() {
return this.props.readonly;
}
get currencyId() {
const recordCurrency = this.props.record.data.currency_id;
return recordCurrency && recordCurrency[0];
}
get currency() {
return session.currencies[this.currencyId];
}
invalidate() {
return this.props.record.setInvalidField(this.props.name);
}
formatMonetary(value) {
return formatMonetary(value, {currencyId: this.totals.currency_id});
}
/**
* This method is the main function of the tax group widget.
* It is called by the TaxGroupComponent and receives the newer tax value.
@ -140,48 +153,21 @@ export class TaxTotalsComponent extends Component {
*/
_onChangeTaxValueByTaxGroup({ oldValue, newValue }) {
if (oldValue === newValue) return;
this.props.update(this.totals);
this.totals.display_rounding = false;
this.props.record.update({ [this.props.name]: this.totals });
delete this.totals.cash_rounding_base_amount_currency;
}
formatData(props) {
let totals = JSON.parse(JSON.stringify(props.value));
const currencyFmtOpts = { currencyId: props.record.data.currency_id && props.record.data.currency_id[0] };
let amount_untaxed = totals.amount_untaxed;
let amount_tax = 0;
let subtotals = [];
for (let subtotal_title of totals.subtotals_order) {
let amount_total = amount_untaxed + amount_tax;
subtotals.push({
'name': subtotal_title,
'amount': amount_total,
'formatted_amount': formatMonetary(amount_total, currencyFmtOpts),
});
let group = totals.groups_by_subtotal[subtotal_title];
for (let i in group) {
amount_tax = amount_tax + group[i].tax_group_amount;
}
}
totals.subtotals = subtotals;
let rounding_amount = totals.display_rounding && totals.rounding_amount || 0;
let amount_total = amount_untaxed + amount_tax + rounding_amount;
totals.amount_total = amount_total;
totals.formatted_amount_total = formatMonetary(amount_total, currencyFmtOpts);
for (let group_name of Object.keys(totals.groups_by_subtotal)) {
let group = totals.groups_by_subtotal[group_name];
for (let key in group) {
group[key].formatted_tax_group_amount = formatMonetary(group[key].tax_group_amount, currencyFmtOpts);
group[key].formatted_tax_group_base_amount = formatMonetary(group[key].tax_group_base_amount, currencyFmtOpts);
}
let totals = JSON.parse(JSON.stringify(toRaw(props.record.data[this.props.name])));
if (!totals) {
return;
}
this.totals = totals;
}
}
TaxTotalsComponent.template = "account.TaxTotalsField";
TaxTotalsComponent.components = { TaxGroupComponent };
TaxTotalsComponent.props = {
...standardFieldProps,
export const taxTotalsComponent = {
component: TaxTotalsComponent,
};
registry.category("fields").add("account-tax-totals-field", TaxTotalsComponent);
registry.category("fields").add("account-tax-totals-field", taxTotalsComponent);

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.TaxGroupComponent" owl="1">
<t t-name="account.TaxGroupComponent">
<tr>
<td class="o_td_label">
<label class="o_form_label o_tax_total_label" t-esc="props.taxGroup.tax_group_name"/>
<label class="o_form_label o_tax_total_label" t-out="props.taxGroup.group_name"/>
</td>
<td class="o_tax_group">
@ -24,37 +24,40 @@
<t t-else="">
<span class="o_tax_group_edit" t-on-click.prevent="() => this.setState('edit')">
<span class="o_tax_group_amount_value o_list_monetary">
<i class="fa fa-pencil me-2"/> <t t-out="props.taxGroup.formatted_tax_group_amount"/>
<i class="fa fa-pencil me-2"/> <t t-out="formatMonetary(props.taxGroup.tax_amount_currency)"/>
</span>
</span>
</t>
</t>
<t t-else="">
<span class="o_tax_group_amount_value o_list_monetary">
<t t-out="props.taxGroup.formatted_tax_group_amount" style="white-space: nowrap;"/>
<t t-out="formatMonetary(props.taxGroup.tax_amount_currency)" style="white-space: nowrap;"/>
</span>
</t>
</td>
</tr>
</t>
<t t-name="account.TaxTotalsField" owl="1">
<table t-if="totals" class="oe_right">
<t t-name="account.TaxTotalsField">
<table t-if="totals" class="float-end">
<tbody>
<t t-foreach="totals.subtotals" t-as="subtotal" t-key="subtotal['name']">
<t t-foreach="totals.subtotals" t-as="subtotal" t-key="subtotal_index">
<tr>
<td class="o_td_label">
<label class="o_form_label o_tax_total_label" t-esc="subtotal['name']"/>
<label class="o_form_label o_tax_total_label" t-out="subtotal.name"/>
</td>
<td class="o_list_monetary">
<span t-att-name="subtotal['name']" style="white-space: nowrap; font-weight: bold;" t-out="subtotal['formatted_amount']"/>
<span t-att-name="subtotal.name"
style="white-space: nowrap; font-weight: bold;"
t-out="formatMonetary(subtotal.base_amount_currency)"/>
</td>
</tr>
<t t-foreach="totals.groups_by_subtotal[subtotal['name']]" t-as="taxGroup" t-key="taxGroup.group_key">
<t t-foreach="subtotal.tax_groups" t-as="taxGroup" t-key="taxGroup_index">
<TaxGroupComponent
currency="currency"
totals="totals"
subtotal="subtotal"
taxGroup="taxGroup"
isReadonly="readonly"
onChangeTaxGroup.bind="_onChangeTaxValueByTaxGroup"
@ -63,13 +66,13 @@
</t>
</t>
<tr t-if="'formatted_rounding_amount' in totals and totals.rounding_amount !== 0 and totals.display_rounding">
<tr t-if="'cash_rounding_base_amount_currency' in totals">
<td class="o_td_label">
<label class="o_form_label o_tax_total_label">Rounding</label>
</td>
<td class="o_list_monetary">
<span
t-out="totals.formatted_rounding_amount"
t-out="formatMonetary(totals.cash_rounding_base_amount_currency)"
/>
</td>
</tr>
@ -83,8 +86,8 @@
<td class="o_list_monetary">
<span
name="amount_total"
t-att-class="Object.keys(totals.groups_by_subtotal).length > 0 ? 'oe_subtotal_footer_separator' : ''"
t-out="totals.formatted_amount_total"
t-att-class="{'oe_subtotal_footer_separator': totals.has_tax_groups}"
t-out="formatMonetary(totals.total_amount_currency)"
style="font-size: 1.3em; font-weight: bold; white-space: nowrap;"
/>
</td>

View file

@ -0,0 +1,6 @@
<?xml version="1.0"?>
<templates>
<t t-name="account.TestsSharedJsPython">
<button t-attf-class="#{state.done ? 'text-success' : ''}" t-on-click="processTests">Test</button>
</t>
</templates>

View file

@ -0,0 +1,169 @@
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { accountTaxHelpers } from "@account/helpers/account_tax";
import { useState, Component } from "@odoo/owl";
export class TestsSharedJsPython extends Component {
static template = "account.TestsSharedJsPython";
static props = {
tests: { type: Array, optional: true },
};
setup() {
super.setup();
this.state = useState({ done: false });
}
processTest(params) {
if (params.test === "taxes_computation") {
let filter_tax_function = null;
if (params.excluded_tax_ids && params.excluded_tax_ids.length) {
filter_tax_function = (tax) => !params.excluded_tax_ids.includes(tax.id);
}
const kwargs = {
product: params.product,
product_uom: params.product_uom,
precision_rounding: params.precision_rounding,
rounding_method: params.rounding_method,
filter_tax_function: filter_tax_function,
};
const results = {
results: accountTaxHelpers.get_tax_details(
params.taxes,
params.price_unit,
params.quantity,
kwargs,
)
};
if (params.rounding_method === "round_globally") {
results.total_excluded_results = accountTaxHelpers.get_tax_details(
params.taxes,
results.results.total_excluded / params.quantity,
params.quantity,
{...kwargs, special_mode: "total_excluded"}
);
results.total_included_results = accountTaxHelpers.get_tax_details(
params.taxes,
results.results.total_included / params.quantity,
params.quantity,
{...kwargs, special_mode: "total_included"}
);
}
return results;
}
if (params.test === "adapt_price_unit_to_another_taxes") {
return {
price_unit: accountTaxHelpers.adapt_price_unit_to_another_taxes(
params.price_unit,
params.product,
params.original_taxes,
params.new_taxes,
{ product_uom: params.product_uom}
)
}
}
if (params.test === "tax_totals_summary") {
const document = this.populateDocument(params.document);
const taxTotals = accountTaxHelpers.get_tax_totals_summary(
document.lines,
document.currency,
document.company,
{cash_rounding: document.cash_rounding}
);
return {tax_totals: taxTotals, soft_checking: params.soft_checking};
}
if (params.test === "global_discount") {
const document = this.populateDocument(params.document);
const baseLines = accountTaxHelpers.prepare_global_discount_lines(
document.lines,
document.company,
params.amount_type,
params.amount,
"global_discount",
);
document.lines.push(...baseLines);
accountTaxHelpers.add_tax_details_in_base_lines(document.lines, document.company);
accountTaxHelpers.round_base_lines_tax_details(document.lines, document.company);
const taxTotals = accountTaxHelpers.get_tax_totals_summary(
document.lines,
document.currency,
document.company,
{cash_rounding: document.cash_rounding}
);
return {tax_totals: taxTotals, soft_checking: params.soft_checking};
}
if (params.test === "down_payment") {
const document = this.populateDocument(params.document);
const baseLines = accountTaxHelpers.prepare_down_payment_lines(
document.lines,
document.company,
params.amount_type,
params.amount,
"down_payment",
);
document.lines = baseLines;
accountTaxHelpers.add_tax_details_in_base_lines(document.lines, document.company);
accountTaxHelpers.round_base_lines_tax_details(document.lines, document.company);
const taxTotals = accountTaxHelpers.get_tax_totals_summary(
document.lines,
document.currency,
document.company,
{cash_rounding: document.cash_rounding}
);
return {
tax_totals: taxTotals,
soft_checking: params.soft_checking,
base_lines_tax_details: this.extractBaseLinesDetails(document),
};
}
if (params.test === "base_lines_tax_details") {
const document = this.populateDocument(params.document);
return {
base_lines_tax_details: this.extractBaseLinesDetails(document),
};
}
}
async processTests() {
const tests = this.props.tests || [];
const results = tests.map(this.processTest.bind(this));
await rpc("/account/post_tests_shared_js_python", { results: results });
this.state.done = true;
}
populateDocument(document) {
const base_lines = document.lines.map(line => accountTaxHelpers.prepare_base_line_for_taxes_computation(null, line));
accountTaxHelpers.add_tax_details_in_base_lines(base_lines, document.company);
accountTaxHelpers.round_base_lines_tax_details(base_lines, document.company);
return {
...document,
lines: base_lines,
}
}
extractBaseLinesDetails(document) {
return document.lines.map(line => ({
total_excluded_currency: line.tax_details.total_excluded_currency,
total_excluded: line.tax_details.total_excluded,
total_included_currency: line.tax_details.total_included_currency,
total_included: line.tax_details.total_included,
delta_total_excluded_currency: line.tax_details.delta_total_excluded_currency,
delta_total_excluded: line.tax_details.delta_total_excluded,
manual_total_excluded_currency: line.manual_total_excluded_currency,
manual_total_excluded: line.manual_total_excluded,
manual_tax_amounts: line.manual_tax_amounts,
taxes_data: line.tax_details.taxes_data.map(tax_data => ({
tax_id: tax_data.tax.id,
tax_amount_currency: tax_data.tax_amount_currency,
tax_amount: tax_data.tax_amount,
base_amount_currency: tax_data.base_amount_currency,
base_amount: tax_data.base_amount,
})),
}));
}
}
registry.category("public_components").add("account.tests_shared_js_python", TestsSharedJsPython);

View file

@ -0,0 +1,45 @@
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { Component, useState } from "@odoo/owl";
export class UploadDropZone extends Component {
static template = "account.UploadDropZone";
static props = {
visible: { type: Boolean, optional: true },
hideZone: { type: Function, optional: true },
dragIcon: { type: String, optional: true },
dragText: { type: String, optional: true },
dragTitle: { type: String, optional: true },
dragCompany: { type: String, optional: true },
dragShowCompany: { type: Boolean, optional: true },
dropZoneTitle: { type: String, optional: true },
dropZoneDescription: { type: String, optional: true },
};
static defaultProps = {
hideZone: () => {},
};
setup() {
this.notificationService = useService("notification");
this.dashboardState = useState(this.env.dashboardState || {});
}
onDrop(ev) {
const selector = '.document_file_uploader.o_input_file';
// look for the closest uploader Input as it may have a context
let uploadInput = ev.target.closest('.o_drop_area').parentElement.querySelector(selector) || document.querySelector(selector);
let files = ev.dataTransfer ? ev.dataTransfer.files : false;
if (uploadInput && !!files) {
uploadInput.files = ev.dataTransfer.files;
uploadInput.dispatchEvent(new Event("change"));
} else {
this.notificationService.add(
_t("Could not upload files"),
{
type: "danger",
});
}
this.props.hideZone();
}
}

View file

@ -0,0 +1,21 @@
.o_drop_area {
width: calc(100% + 2px);
height: calc(100% + 2px);
position: absolute;
background-color: mix($o-brand-primary, $o-view-background-color, 15%);
border: 3px dashed $o-brand-primary;
z-index: 3;
left: -1px;
top: -1px;
i {
justify-content: center;
display: flex;
align-items: center;
height: 100%;
}
.upload_badge {
top: 50% !important;
left: 50% !important;
transform: translate(-50%,-50%);
}
}

View file

@ -0,0 +1,60 @@
<templates>
<t t-name="account.UploadDropZone">
<t t-if="dashboardState.isDragging or props.visible">
<div
class="o_drop_area d-flex align-items-center justify-content-center flex-column"
t-att-class="{
'drag_to_card': props.visible,
}"
t-on-click="() => this.env.setDragging(false)"
t-on-dragover.prevent="()=>{}"
t-on-dragleave="props.hideZone"
t-on-drop.prevent="onDrop"
>
<t t-if="props.dragIcon and props.dragText">
<div class="text-align-center pe-none">
<img class="img-fluid" t-att-src="props.dragIcon"/>
</div>
<t t-if="props.visible">
<div class="position-absolute upload_badge pe-none">
<i class="fa fa-circle fa-stack-2x text-primary"/>
<i class="fa fa-upload fa-stack-1x fa-inverse"/>
</div>
</t>
<div class="h3 pe-none" t-att-class="{'text-primary': props.visible}">
<t t-out="props.dragTitle"/>
</div>
<t t-if="props.dragShowCompany">
<div t-att-class="{'text-primary': props.visible}">
<span class="small fw-bold">(<t t-out="props.dragCompany"/>)</span>
</div>
</t>
<div class="pe-none">
<span t-att-class="{'invisible': !props.visible, 'text-primary': props.visible}">
<t t-out="props.dragText"/>
</span>
</div>
</t>
<t t-elif="props.visible">
<img
class="img-fluid"
src="/account/static/src/img/bill.svg"
style="height: auto; width: 120px;"
/>
<span class="position-absolute fa-stack-2x mt-2">
<i class="fa fa-circle fa-stack-2x text-primary"></i>
<i class="fa fa-upload fa-stack-1x fa-inverse"></i>
</span>
<h2 class="mt-5 fw-bold text-primary text-center">
<t t-out="props.dropZoneTitle"/>
</h2>
<span class="mt-2 text-primary text-center">
<t t-out="props.dropZoneDescription"/>
</span>
</t>
</div>
</t>
</t>
</templates>

View file

@ -0,0 +1,58 @@
import { _t } from "@web/core/l10n/translation";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
class X2ManyButtons extends Component {
static template = "account.X2ManyButtons";
static props = {
...standardFieldProps,
treeLabel: { type: String },
nbRecordsShown: { type: Number, optional: true },
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
}
async openTreeAndDiscard() {
const ids = this.currentField.currentIds;
await this.props.record.discard();
const context = this.currentField.resModel === "account.move"
? { list_view_ref: "account.view_duplicated_moves_tree_js" }
: {};
this.action.doAction({
name: this.props.treeLabel,
type: "ir.actions.act_window",
res_model: this.currentField.resModel,
views: [
[false, "list"],
[false, "form"],
],
domain: [["id", "in", ids]],
context: context,
});
}
async openFormAndDiscard(id) {
const action = await this.orm.call(this.currentField.resModel, "action_open_business_doc", [id], {});
await this.props.record.discard();
this.action.doAction(action);
}
get currentField() {
return this.props.record.data[this.props.name];
}
}
X2ManyButtons.template = "account.X2ManyButtons";
registry.category("fields").add("x2many_buttons", {
component: X2ManyButtons,
relatedFields: [{ name: "display_name", type: "char" }],
extractProps: ({ attrs, string }) => ({
treeLabel: string || _t("Records"),
nbRecordsShown: attrs.nb_records_shown ? parseInt(attrs.nb_records_shown) : 3,
}),
});

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.X2ManyButtons">
<div class="d-flex align-items-center">
<t t-foreach="this.currentField.records.slice(0, props.nbRecordsShown)" t-as="record" t-key="record.id">
<button class="btn btn-link p-0"
t-on-click="() => this.openFormAndDiscard(record.resId)"
t-att-data-hotkey="`shift+${record_index + 1}`"
t-out="record.data.display_name"/>
<span t-if="!record_last" class="pe-1">,</span>
</t>
<t t-if="this.currentField.count gt props.nbRecordsShown">
<button class="btn btn-link p-0" t-on-click="() => this.openTreeAndDiscard()" data-hotkey="shift+4">... (View all)</button>
</t>
</div>
</t>
</templates>

View file

@ -28,7 +28,10 @@
clear: both;
float: right;
min-width: 260px;
padding-top: 20px;
/* The max-width ensures that the widget is not too wide in larger screens,
but does not affect the width once the screen size decreases */
max-width: 400px;
margin-left: auto;
}
.oe_account_terms {
@ -59,3 +62,17 @@
.o_field_account_resequence_widget {
width: 100%;
}
.o_field_account_json_checkboxes {
div.form-check {
display: inline-block;
}
i.fa {
margin-left: 2px;
}
}
.o_account_move_form_view .o_cell:has(>div[name="journal_div"]:empty) {
display: none !important;
}

View file

@ -0,0 +1,13 @@
.o_popover_header {
padding: 5px 0 5px 8px;
border-bottom: 1px solid #ccc;
}
.o_memo_content {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}

View file

@ -0,0 +1,23 @@
#payment_terms_note_id > p {
margin-bottom: 0 !important;
}
.avoid-page-break-inside {
page-break-inside: avoid;
}
.justify-text {
text-align:justify;
text-justify:inter-word;
}
#qrcode_odoo_logo {
-webkit-transform:translate(-50%,-50%);
height:18%;
width:18%;
border-color: white !important;
}
.tax_computation_company_currency {
margin-bottom: 5px;
}

Some files were not shown because too many files have changed in this diff Show more