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:
@@ -12,6 +12,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -47,6 +48,26 @@ func main() {
|
|||||||
cfg := tools.DefaultConfig()
|
cfg := tools.DefaultConfig()
|
||||||
cfg.LoadFromEnv()
|
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: Odoo Go Server 19.0")
|
||||||
log.Printf("odoo: database: %s@%s:%d/%s", cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName)
|
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)
|
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)
|
// Check if setup is needed (first boot)
|
||||||
if service.NeedsSetup(ctx, pool) {
|
if service.NeedsSetup(ctx, pool) {
|
||||||
log.Println("odoo: database is empty — setup wizard will be shown at /web/setup")
|
log.Println("odoo: database is empty — setup wizard will be shown at /web/setup")
|
||||||
|
|||||||
BIN
frontend/account/static/demo/bank_opening_statement.pdf
Normal file
BIN
frontend/account/static/demo/bank_opening_statement.pdf
Normal file
Binary file not shown.
BIN
frontend/account/static/demo/bank_statement_one_month_old.pdf
Normal file
BIN
frontend/account/static/demo/bank_statement_one_month_old.pdf
Normal file
Binary file not shown.
BIN
frontend/account/static/demo/in_invoice_yourcompany_demo_1.pdf
Normal file
BIN
frontend/account/static/demo/in_invoice_yourcompany_demo_1.pdf
Normal file
Binary file not shown.
BIN
frontend/account/static/demo/in_invoice_yourcompany_demo_2.pdf
Normal file
BIN
frontend/account/static/demo/in_invoice_yourcompany_demo_2.pdf
Normal file
Binary file not shown.
BIN
frontend/account/static/description/icon.png
Normal file
BIN
frontend/account/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
1
frontend/account/static/description/icon.svg
Normal file
1
frontend/account/static/description/icon.svg
Normal 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 |
BIN
frontend/account/static/description/icon_hi.png
Normal file
BIN
frontend/account/static/description/icon_hi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/account/static/description/l10n.png
Normal file
BIN
frontend/account/static/description/l10n.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
1
frontend/account/static/description/l10n.svg
Normal file
1
frontend/account/static/description/l10n.svg
Normal 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 |
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.o_widget_account_file_uploader {
|
||||||
|
button.oe_kanban_action {
|
||||||
|
a {
|
||||||
|
color: var(--btn-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.account_document_state_popover {
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account_document_state_popover_clone {
|
||||||
|
&:hover {
|
||||||
|
color: $o-enterprise-action-color !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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 });
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 parent’s 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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
173
frontend/account/static/src/components/tax_totals/tax_totals.js
Normal file
173
frontend/account/static/src/components/tax_totals/tax_totals.js
Normal 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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
78
frontend/account/static/src/css/account.css
Normal file
78
frontend/account/static/src/css/account.css
Normal 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
Reference in New Issue
Block a user