Eliminate Python dependency: embed frontend assets in odoo-go

- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/
  directory (2925 files, 43MB) — no more runtime reads from Python Odoo
- Replace OdooAddonsPath config with FrontendDir pointing to local frontend/
- Rewire bundle.go, static.go, templates.go, webclient.go to read from
  frontend/ instead of external Python Odoo addons directory
- Auto-detect frontend/ and build/ dirs relative to binary in main.go
- Delete obsolete Python helper scripts (tools/*.py)

The Go server is now fully self-contained: single binary + frontend/ folder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-03-31 23:09:12 +02:00
parent 0ed29fe2fd
commit 8741282322
2933 changed files with 280644 additions and 264 deletions

View File

@@ -12,6 +12,7 @@ import (
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/jackc/pgx/v5/pgxpool"
@@ -47,6 +48,26 @@ func main() {
cfg := tools.DefaultConfig()
cfg.LoadFromEnv()
// Auto-detect frontend/ directory relative to the binary if not set
if cfg.FrontendDir == "" {
exe, _ := os.Executable()
candidate := filepath.Join(filepath.Dir(exe), "frontend")
if _, err := os.Stat(candidate); err != nil {
// Try relative to working directory
candidate = "frontend"
}
cfg.FrontendDir = candidate
}
// Auto-detect build/ directory
if cfg.BuildDir == "" {
exe, _ := os.Executable()
candidate := filepath.Join(filepath.Dir(exe), "build")
if _, err := os.Stat(candidate); err != nil {
candidate = "build"
}
cfg.BuildDir = candidate
}
log.Printf("odoo: Odoo Go Server 19.0")
log.Printf("odoo: database: %s@%s:%d/%s", cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName)
@@ -87,6 +108,12 @@ func main() {
log.Fatalf("odoo: schema init failed: %v", err)
}
// Migrate schema: add any missing columns for newly registered fields
log.Println("odoo: running schema migration...")
if err := service.MigrateSchema(ctx, pool); err != nil {
log.Printf("odoo: schema migration warning: %v", err)
}
// Check if setup is needed (first boot)
if service.NeedsSetup(ctx, pool) {
log.Println("odoo: database is empty — setup wizard will be shown at /web/setup")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M38 46H12c-4.418 0-8-3.498-8-7.814V4h30c2.21 0 4 1.75 4 3.907V46Z" fill="#2EBCFA"/><path d="M12 46h26a7.999 7.999 0 0 0 8-8H20a8 8 0 0 1-8 8Z" fill="#144496"/><path d="M38 8C23.408 8 10.687 16.014 4 27.88V4h30a4 4 0 0 1 4 4Z" fill="#088BF5"/><path d="M20.425 34h1.181v-1.707c3.23-.198 5.394-1.791 5.394-4.435v-.021c0-2.332-1.495-3.498-4.442-4.143l-.952-.198v-3.02c1.035.167 1.715.74 1.86 1.708l.011.02 3.283-.01.01-.01c-.115-2.53-2.007-4.216-5.164-4.435V16h-1.18v1.739c-3.074.145-5.28 1.728-5.28 4.33v.021c0 2.343 1.547 3.602 4.338 4.216l.941.198v3.082c-1.223-.115-1.934-.625-2.101-1.54l-.01-.022-3.293.01-.021.011c.084 2.665 2.237 4.133 5.425 4.258V34Zm-1.777-12.19v-.022c0-.75.606-1.228 1.777-1.332v2.769c-1.213-.323-1.777-.729-1.777-1.416Zm4.85 6.423v.02c0 .802-.68 1.229-1.892 1.333v-2.821c1.36.343 1.892.697 1.892 1.468Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#o_icon_l10n__a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M25 0c13.807 0 25 11.193 25 25S38.807 50 25 50 0 38.807 0 25 11.193 0 25 0Zm0 48c2.105 0 4.598-1.844 6.682-6.188 1.047-2.181 1.902-4.832 2.481-7.812H15.837c.58 2.98 1.434 5.631 2.48 7.812C20.403 46.156 22.896 48 25 48Zm-9.503-16h19.006c.322-2.202.497-4.552.497-7 0-2.448-.175-4.798-.497-7H15.497A48.566 48.566 0 0 0 15 25c0 2.448.175 4.798.497 7Zm.34-16c.58-2.98 1.434-5.631 2.48-7.812C20.403 3.845 22.896 2 25 2c2.105 0 4.598 1.845 6.682 6.188 1.047 2.181 1.902 4.832 2.481 7.812H15.837Zm20.686 2c.31 2.221.477 4.57.477 7 0 2.43-.166 4.779-.477 7h10.392A22.987 22.987 0 0 0 48 25c0-2.44-.38-4.793-1.085-7H36.523Zm9.65-2h-9.974c-1.08-5.826-3.177-10.599-5.837-13.372C37.502 4.333 43.35 9.368 46.172 16ZM13.8 16H3.828c2.822-6.632 8.67-11.667 15.81-13.372-2.66 2.773-4.757 7.546-5.837 13.372ZM3.085 18h10.392A50.707 50.707 0 0 0 13 25c0 2.43.166 4.779.477 7H3.085A22.986 22.986 0 0 1 2 25c0-2.44.38-4.793 1.085-7Zm27.277 29.372c2.66-2.773 4.757-7.546 5.837-13.372h9.974c-2.823 6.632-8.67 11.667-15.811 13.372Zm-10.724 0C12.498 45.667 6.65 40.632 3.828 34H13.8c1.08 5.826 3.177 10.599 5.837 13.372Z" fill="#1AD3BB"/></g><defs><clipPath id="o_icon_l10n__a"><path fill="#fff" d="M0 0h50v50H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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

@@ -0,0 +1,94 @@
import { registry } from "@web/core/registry";
import { createElement, append } from "@web/core/utils/xml";
import { Notebook } from "@web/core/notebook/notebook";
import { formView } from "@web/views/form/form_view";
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 AccountMoveFormController extends FormController {
setup() {
super.setup();
this.account_move_service = useService("account_move");
}
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 {
static template = "account.AccountMoveFormNotebook";
static props = {
...Notebook.props,
onBeforeTabSwitch: { type: Function, optional: true },
};
async changeTabTo(page_id) {
if (this.props.onBeforeTabSwitch) {
await this.props.onBeforeTabSwitch(page_id);
}
this.state.currentPage = page_id;
}
}
export class AccountMoveFormRenderer extends FormRenderer {
static components = {
...FormRenderer.components,
AccountMoveFormNotebook: AccountMoveFormNotebook,
};
async saveBeforeTabChange() {
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();
if (scrollPos) {
contentEl.scrollTop = scrollPos;
}
}
}
}
export class AccountMoveFormCompiler extends FormCompiler {
compileNotebook(el, params) {
const originalNoteBook = super.compileNotebook(...arguments);
const noteBook = createElement("AccountMoveFormNotebook");
for (const attr of originalNoteBook.attributes) {
noteBook.setAttribute(attr.name, attr.value);
}
noteBook.setAttribute("onBeforeTabSwitch", "() => __comp__.saveBeforeTabChange()");
const slots = originalNoteBook.childNodes;
append(noteBook, [...slots]);
return noteBook;
}
}
export const AccountMoveFormView = {
...formView,
Renderer: AccountMoveFormRenderer,
Compiler: AccountMoveFormCompiler,
Controller: AccountMoveFormController,
};
registry.category("views").add("account_move_form", AccountMoveFormView);

View File

@@ -0,0 +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">
<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

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.AccountPaymentField">
<div>
<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-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>
</table>
</div>
</div>
</t>
<t t-name="account.AccountPaymentPopOver">
<div class="account_payment_popover">
<h3 t-if="props.title" class="popover-header"><t t-out="props.title"/></h3>
<div class="px-2">
<div>
<table>
<tr>
<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-out="props.amount_foreign_currency"/>)
</t>
</td>
</tr>
<tr>
<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 class="fw-bolder">Date:</td>
<td class="ps-1"><t t-out="props.date"/></td>
</tr>
<tr>
<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>
<button class="btn btn-sm btn-primary js_unreconcile_payment float-start" t-if="!props.is_exchange" style="margin-top:5px; margin-bottom:5px;" groups="account.group_account_invoice" t-on-click="() => props._onRemoveMoveReconcile(props.move_id, props.partial_id)">Unreconcile</button>
<button class="btn btn-sm btn-secondary js_open_payment float-end" style="margin-top:5px; margin-bottom:5px;" t-on-click="() => props._onOpenMove(props.move_id)">View</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,84 @@
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 { 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";
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() {
const position = localization.direction === "rtl" ? "bottom" : "left";
this.popover = usePopover(AccountPaymentPopOver, { position });
this.orm = useService("orm");
this.action = useService("action");
}
getInfo() {
const info = this.props.record.data[this.props.name] || {
content: [],
outstanding: false,
title: "",
move_id: this.props.record.resId,
};
for (const [key, value] of Object.entries(info.content)) {
value.index = key;
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.formattedDate = formatDate(deserializeDate(value.date))
}
}
return {
lines: info.content,
outstanding: info.outstanding,
title: info.title,
moveId: info.move_id,
};
}
onInfoClick(ev, line) {
this.popover.open(ev.currentTarget, {
title: _t("Journal Entry Info"),
...line,
_onRemoveMoveReconcile: this.removeMoveReconcile.bind(this),
_onOpenMove: this.openMove.bind(this),
});
}
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();
}
async removeMoveReconcile(moveId, partialId) {
this.popover.close();
await this.orm.call(this.props.record.resModel, 'js_remove_outstanding_partial', [moveId, partialId], {});
await this.props.record.model.root.load();
}
async openMove(moveId) {
const action = await this.orm.call(this.props.record.resModel, 'action_open_business_doc', [moveId], {});
this.action.doAction(action);
}
}
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

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<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="value.changeLines" t-as="changeLine" t-key="changeLine.id">
<ChangeLine changeLine="changeLine" ordering="value.ordering"/>
</t>
</tbody>
</table>
</t>
<t t-name="account.ResequenceChangeLine">
<tr>
<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

@@ -0,0 +1,22 @@
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class ChangeLine extends Component {
static template = "account.ResequenceChangeLine";
static props = ["changeLine", "ordering"];
}
class ShowResequenceRenderer extends Component {
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" };
}
}
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

@@ -0,0 +1,62 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field";
export class AccountTypeSelection extends SelectionField {
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",
},
];
}
}
export const accountTypeSelection = {
...selectionField,
component: AccountTypeSelection,
};
registry.category("fields").add("account_type_selection", accountTypeSelection);

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<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>
</templates>

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

@@ -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

@@ -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

@@ -0,0 +1,30 @@
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class ListItem extends Component {
static template = "account.GroupedItemTemplate";
static props = ["item_vals", "options"];
}
class ListGroup extends Component {
static template = "account.GroupedItemsTemplate";
static components = { ListItem };
static props = ["group_vals", "options"];
}
class ShowGroupedList extends Component {
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: [] } };
}
}
registry.category("fields").add("grouped_view_widget", {
component: ShowGroupedList,
});

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<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="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="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="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">
<tr style="background-color: #dee2e6;">
<td t-attf-colspan="{{props.options.columns.length}}">
<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">
<ListItem item_vals="item_vals[2]" options="props.options"/>
</t>
</tbody>
<tr t-name="account.GroupedItemTemplate">
<t t-foreach="props.options.columns" t-as="col" t-key="col_index">
<td t-out="props.item_vals[col['field']]" t-attf-class="{{col['class']}}"/>
</t>
</tr>
<t t-name="account.OpenMoveTemplate">
<a href="#" t-out="widget.value"/>
</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

@@ -0,0 +1,35 @@
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
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(),
};
}
async openAction() {
return this.action.doActionButton({
type: "object",
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", {
...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

@@ -0,0 +1,27 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
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");
}
async openMove(ev) {
this.action.doActionButton({
type: "object",
resId: this.props.record.resId,
name: "action_open_business_doc",
resModel: this.props.record.resModel,
});
}
}
registry.category("fields").add("open_move_widget", {
component: OpenMoveWidget,
});

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<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.selectedSectionId,
};
},
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

@@ -0,0 +1,82 @@
// 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 {
--o-SectionAndNote-border-color: #{$gray-500};
$_SectionAndNote-transition: 0.1s ease-in-out;
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);
}
}
.o_field_section_and_note_text {
> span {
white-space: pre-wrap !important;
}
}

View File

@@ -0,0 +1,606 @@
import { Component, useEffect, onWillRender } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { x2ManyCommands } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
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 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
*
* @override
*/
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"];
this.parentSectionMap = new Map();
useEffect(
(editedRecord) => this.focusToName(editedRecord),
() => [this.editedRecord]
);
onWillRender(() => {
this.buildParentSectionMap();
});
}
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');
}
buildParentSectionMap() {
this.parentSectionMap.clear();
let lastSection = null;
let lastSubSection = null;
for (const record of this.props.list.records) {
if (record.data.display_type === DISPLAY_TYPES.SECTION) {
lastSection = record;
lastSubSection = null;
this.parentSectionMap.set(record, null);
} else if (record.data.display_type === DISPLAY_TYPES.SUBSECTION) {
lastSubSection = record;
this.parentSectionMap.set(record, lastSection);
} else {
this.parentSectionMap.set(record, lastSubSection ?? lastSection);
}
}
}
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.isNew && this.isSectionOrNote(editRec)) {
const col = this.columns.find((c) => c.name === this.titleField);
this.focusCell(col, 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 [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 = this.parentSectionMap.get(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 = this.parentSectionMap.get(parentSection);
return parentSection.data[fieldName] || grandParent?.data[fieldName];
}
return !!parentSection.data[fieldName];
}
getRowClass(record) {
const existingClasses = super.getRowClass(record);
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) {
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, record);
}
return columns;
}
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 };
} else {
return { ...col };
}
});
}
async moveSectionDown(record) {
const canProceed = await this.props.list.leaveEditMode({ canAbandon: false });
if (!canProceed) {
return;
}
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 = this.parentSectionMap.get(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;
}
}
export class ListSectionAndNoteText extends SectionAndNoteText {
get componentToUse() {
return this.props.record.data.display_type !== "line_section"
? ListTextField
: super.componentToUse;
}
}
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

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.SectionAndNoteListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//table" position="attributes">
<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">
<t t-component="componentToUse" t-props="props"/>
</t>
</templates>

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

@@ -0,0 +1,24 @@
.o_tax_group { width: 0% }
.o_tax_group_edit {
white-space: nowrap;
}
.o_tax_group_edit:hover {
color: #00A09D;
cursor: pointer;
}
.o_tax_group_editable .o_tax_group_amount_value input {
width: 65%;
float: right;
text-align: right;
}
.o_tax_group_editable .o_tax_group_amount_value::before {
content: ' ';
}
.o_tax_total_label{
margin-bottom: 0 !important;
}

View File

@@ -0,0 +1,173 @@
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 {
Component,
onPatched,
onWillUpdateProps,
onWillRender,
toRaw,
useRef,
useState,
} from "@odoo/owl";
import { useNumpadDecimal } from "@web/views/fields/numpad_decimal_hook";
/**
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 } = 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
}
});
onWillUpdateProps(() => {
this.setState("readonly");
});
useNumpadDecimal();
}
formatMonetary(value) {
return formatMonetary(value, {currencyId: this.props.totals.currency_id});
}
//--------------------------------------------------------------------------
// Main methods
//--------------------------------------------------------------------------
/**
* The purpose of this method is to change the state of the component.
* It can have one of the following three states:
* - readonly: display in read-only mode of the field,
* - edit: display with a html input field,
* - disable: display with a html input field that is disabled.
*
* If a value other than one of these 3 states is passed as a parameter,
* the component is set to readonly by default.
*
* @param {String} value
*/
setState(value) {
if (["readonly", "edit", "disable"].includes(value)) {
this.state.value = value;
}
else {
this.state.value = "readonly";
}
}
/**
* This method handles the "_onChangeTaxValue" event. In this method,
* we get the new value for the tax group, we format it and we call
* the method to recalculate the tax lines. At the moment the method
* is called, we disable the html input field.
*
* In case the value has not changed or the tax group is equal to 0,
* the modification does not take place.
*/
_onChangeTaxValue() {
this.setState("disable"); // Disable the input
const oldValue = this.props.taxGroup.tax_amount_currency;
let newValue;
try {
newValue = parseFloat(this.inputTax.el.value); // Get the new value
} catch {
this.inputTax.el.value = oldValue;
this.setState("edit");
return;
}
// The newValue can"t be equals to 0
if (newValue === oldValue || newValue === 0) {
this.setState("readonly");
return;
}
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.id,
});
}
}
/**
Widget used to display tax totals by tax groups for invoices, PO and SO,
and possibly allowing editing them.
Note that this widget requires the object it is used on to have a
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);
onWillRender(() => this.formatData(this.props));
}
get readonly() {
return this.props.readonly;
}
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.
*
* It is responsible for triggering an event to notify the ORM of a change.
*/
_onChangeTaxValueByTaxGroup({ oldValue, newValue }) {
if (oldValue === newValue) return;
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(toRaw(props.record.data[this.props.name])));
if (!totals) {
return;
}
this.totals = totals;
}
}
export const taxTotalsComponent = {
component: TaxTotalsComponent,
};
registry.category("fields").add("account-tax-totals-field", taxTotalsComponent);

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.TaxGroupComponent">
<tr>
<td class="o_td_label">
<label class="o_form_label o_tax_total_label" t-out="props.taxGroup.group_name"/>
</td>
<td class="o_tax_group">
<t t-if="!props.isReadonly">
<t t-if="['edit', 'disable'].includes(state.value)">
<span class="o_tax_group_edit_input" t-ref="numpadDecimal">
<input
type="text"
t-ref="taxValueInput"
class="o_field_float
o_field_number o_input"
t-att-disabled="state.value === 'disable'"
t-on-change.prevent="_onChangeTaxValue"
t-on-blur="_onChangeTaxValue"/>
</span>
</t>
<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="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="formatMonetary(props.taxGroup.tax_amount_currency)" style="white-space: nowrap;"/>
</span>
</t>
</td>
</tr>
</t>
<t t-name="account.TaxTotalsField">
<table t-if="totals" class="float-end">
<tbody>
<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-out="subtotal.name"/>
</td>
<td class="o_list_monetary">
<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="subtotal.tax_groups" t-as="taxGroup" t-key="taxGroup_index">
<TaxGroupComponent
totals="totals"
subtotal="subtotal"
taxGroup="taxGroup"
isReadonly="readonly"
onChangeTaxGroup.bind="_onChangeTaxValueByTaxGroup"
invalidate.bind="invalidate"
/>
</t>
</t>
<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="formatMonetary(totals.cash_rounding_base_amount_currency)"
/>
</td>
</tr>
<!-- Total amount with all taxes-->
<tr>
<td class="o_td_label">
<label class="o_form_label o_tax_total_label">Total</label>
</td>
<td class="o_list_monetary">
<span
name="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>
</tr>
</tbody>
</table>
</t>
</templates>

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

@@ -0,0 +1,78 @@
.openerp div.oe_account_help {
background : #D6EBFF;
width: 100%;
padding: 10px;
border: 3px solid #C1D4E6;
}
.openerp p.oe_account_font_help{
text-align: left;
font-weight: bold;
margin: 0px;
font-size: 14px;
}
.openerp p.oe_account_font_content{
margin-left: 30px;
font-size: 14px;
}
.openerp p.oe_account_font_title{
margin-top: 7px;
font-size: 15px;
font-style: italic;
color: grey;
}
.oe_invoice_outstanding_credits_debits {
clear: both;
float: right;
min-width: 260px;
/* 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 {
flex: auto !important;
}
@media (max-width: 991.98px) {
/* The purpose is to put the narration below the totals in the tab 'Invoice Lines'
instead of above for the mobile view */
.o_form_view .oe_invoice_lines_tab {
display: flex;
flex-direction: column-reverse;
}
.o_form_view .oe_invoice_lines_tab .oe_invoice_outstanding_credits_debits {
min-width: initial;
width: 50%;
}
}
@media (max-width: 767.98px) {
.o_form_view .oe_invoice_lines_tab .oe_invoice_outstanding_credits_debits {
min-width: initial;
width: 100%;
}
}
.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;
}

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