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:
@@ -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;
|
||||
}
|
||||
31
frontend/account/static/src/css/account_bank_and_cash.css
Normal file
31
frontend/account/static/src/css/account_bank_and_cash.css
Normal file
@@ -0,0 +1,31 @@
|
||||
.openerp .oe_force_bold {
|
||||
font-weight: bold !important;
|
||||
}
|
||||
.openerp label.oe_open_balance{
|
||||
margin-right: -18px;
|
||||
}
|
||||
.openerp label.oe_subtotal_footer_separator{
|
||||
float:right;
|
||||
width: 184px !important;
|
||||
}
|
||||
.openerp label.oe_mini_subtotal_footer_separator{
|
||||
margin-right: -14px;
|
||||
}
|
||||
.openerp .oe_account_total, .openerp .oe_pos_total {
|
||||
margin-left: -2px;
|
||||
}
|
||||
.openerp label.oe_real_closing_balance{
|
||||
min-width: 184px !important;
|
||||
}
|
||||
.openerp label.oe_difference, .openerp label.oe_pos_difference {
|
||||
margin-right: -10px;
|
||||
padding-left: 10px !important;
|
||||
min-width: 195px !important;
|
||||
}
|
||||
.openerp .oe_opening_total{
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.o_payment_label{
|
||||
padding-right: 20px;
|
||||
}
|
||||
13
frontend/account/static/src/css/account_payment.scss
Normal file
13
frontend/account/static/src/css/account_payment.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.o_popover_header {
|
||||
padding: 5px 0 5px 8px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.o_memo_content {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
23
frontend/account/static/src/css/report_invoice.css
Normal file
23
frontend/account/static/src/css/report_invoice.css
Normal file
@@ -0,0 +1,23 @@
|
||||
#payment_terms_note_id > p {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.avoid-page-break-inside {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.justify-text {
|
||||
text-align:justify;
|
||||
text-justify:inter-word;
|
||||
}
|
||||
|
||||
#qrcode_odoo_logo {
|
||||
-webkit-transform:translate(-50%,-50%);
|
||||
height:18%;
|
||||
width:18%;
|
||||
border-color: white !important;
|
||||
}
|
||||
|
||||
.tax_computation_company_currency {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
2739
frontend/account/static/src/helpers/account_tax.js
Normal file
2739
frontend/account/static/src/helpers/account_tax.js
Normal file
File diff suppressed because it is too large
Load Diff
4
frontend/account/static/src/img/Odoo_logo_O.svg
Normal file
4
frontend/account/static/src/img/Odoo_logo_O.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="150" height="150" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="150" height="150" fill="white"/>
|
||||
<path d="M75 150C60.1664 150 45.666 145.601 33.3323 137.36C20.9986 129.119 11.3856 117.406 5.70907 103.701C0.032495 89.9968 -1.45275 74.9168 1.44114 60.3683C4.33503 45.8197 11.4781 32.456 21.967 21.967C32.456 11.4781 45.8197 4.33503 60.3683 1.44114C74.9168 -1.45275 89.9968 0.032495 103.701 5.70907C117.406 11.3856 129.119 20.9986 137.36 33.3323C145.601 45.666 150 60.1664 150 75C150 94.8913 142.098 113.968 128.033 128.033C113.968 142.098 94.8913 150 75 150V150ZM75 119C83.7024 119 92.2094 116.419 99.4451 111.585C106.681 106.75 112.32 99.8781 115.651 91.8381C118.981 83.7982 119.852 74.9512 118.155 66.4161C116.457 57.8809 112.266 50.0408 106.113 43.8873C99.9592 37.7338 92.1192 33.5432 83.584 31.8455C75.0488 30.1477 66.2019 31.0191 58.162 34.3493C50.122 37.6796 43.2502 43.3192 38.4154 50.555C33.5806 57.7907 31 66.2977 31 75C31 86.6696 35.6357 97.8611 43.8873 106.113C52.1389 114.364 63.3305 119 75 119V119Z" fill="#714B67"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
35
frontend/account/static/src/img/bank.svg
Normal file
35
frontend/account/static/src/img/bank.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg width="64" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m58.572 41.734-2.105-1.215-26.31 15.139 2.235 1.29c.654.378 1.714.378 2.364 0L58.58 43.103c.651-.378.646-.991-.008-1.37Z" fill="#fff"/>
|
||||
<path d="M53.442 38.772c-6.626 4.067-12.926 8.668-19.345 13.085L13.867 38.84l-5.22 3.034c-.65.378-.648.991.008 1.37l20.59 11.887.912.527 26.31-15.14-3.025-1.746Z" fill="#C1DBF6"/>
|
||||
<path d="M34.835 28.03c-.656-.38-1.713-.38-2.364 0L13.868 38.84l20.23 13.017c6.418-4.417 12.718-9.018 19.343-13.085L34.835 28.029Z" fill="#374874"/>
|
||||
<path d="M33.278 57.215a2.303 2.303 0 0 1-.886-.267L8.655 43.244c-.312-.19-.482-.389-.494-.69l-.003 1.361c-.001.25.163.5.493.69l23.737 13.704c.333.19.725.27 1.107.282v-1.364a2.9 2.9 0 0 1-.217-.012Z" fill="#FBDBD0"/>
|
||||
<path d="M59.061 42.472c-.025.283-.25.493-.481.631L34.756 56.948c-.38.208-.83.296-1.261.279v1.364c.43.01.879-.071 1.257-.282l23.824-13.844c.082-.05.16-.107.232-.17.142-.134.252-.31.254-.51l.003-1.361c0 .016-.002.032-.004.048Z" fill="#fff"/>
|
||||
<path d="m28.738 51.75-.003 1.118c.043 1.033.896 1.72 1.942 2.123V53.88c-1.021-.388-1.863-1.06-1.94-2.128Z" fill="#FBDBD0"/>
|
||||
<path d="M37.56 51.918c.06-.634-.283-1.278-1.032-1.794l.03-10.671-4.725 2.728c-.017.01-.033.016-.05.025v10.19c-.481.43-.903.912-1.2 1.439 0 .197.094 1.156.094 1.156.935.36 2.023.493 2.822.439 1.442-.077 3.375-.562 3.978-2.024.15-.345.09-1.545.086-1.502 0 .005-.003.009-.003.014Z" fill="#fff"/>
|
||||
<path d="m31.28 42.233.012.01-1.275-.741-.024 8.47c-1.688 1-1.672 2.607.048 3.6.17.098.353.184.542.263a4.51 4.51 0 0 1 .22-.348l.079-.108c.057-.078.116-.155.178-.23a6.48 6.48 0 0 1 .34-.383c.123-.127.25-.251.383-.37v-10.19c-.21.106-.38.105-.502.027Z" fill="#C1DBF6"/>
|
||||
<path d="M37.802 51.775h-.005c0-.649-.367-1.267-1.04-1.764l.03-10.69-.459.265c-.008 3.352-.02 11.604-.04 11.67-.021.233-.14.439-.29.614-.74.772-1.914.972-2.946.946-.992-.056-2.226-.335-2.738-1.261a.983.983 0 0 1-.096-.447l.027-9.474-.456-.265-.024 8.48c-1.344.836-1.287 1.75-1.259 3.03.21 3.722 9.82 3.958 9.296-.7-.003-.162-.004-.281 0-.404Zm-.536 1.544c-1.214 2.752-8.018 2.421-8.303-.45l.003-1.099h.003c-.006-.493.273-.969.794-1.368l-.002.722v.024c.097 1.54 2.035 2.09 3.358 2.13 1.145.01 2.424-.243 3.229-1.114.217-.25.375-.559.399-.897.011-.008.007-.637.009-.668.412.388.615.831.58 1.279h.007c-.005.41.04 1.154-.077 1.441Z" fill="#374874"/>
|
||||
<path d="m21.17 36.36-.023 8.13c-.753.484-1.133 1.094-1.132 1.705l-.001-.005-.003 1.118c.05 1.191 1.18 1.924 2.437 2.29 0 0-.663-2.176.085-3.586v-8.86l-1.363-.792Z" fill="#FBDBD0"/>
|
||||
<path d="M28.85 46.215c0 .043-.008.086-.012.13 0 .008-.003.016-.004.025.024-.689-.36-1.38-1.15-1.887l.012-4.33-5.163-3v8.86c-.748 1.41-.085 3.584-.085 3.584.807.235 1.666.319 2.328.274 1.441-.077 3.373-.562 3.977-2.024.15-.345.08-1.26.096-1.632Z" fill="#C1DBF6"/>
|
||||
<path d="M29.097 46.28h-.006c0-.65-.367-1.268-1.04-1.765l.012-4.149-.456-.265-.016 5.569a1.106 1.106 0 0 1-.245.64c-1.004 1.083-2.733 1.174-4.095.848-.729-.158-1.779-.784-1.739-1.505l.026-9.079-.456-.265-.023 8.043c-1.378.89-1.272 1.703-1.259 3.03.153 3.674 9.81 4 9.296-.698-.002-.163-.004-.284 0-.405Zm-.537 1.543c-1.14 2.747-8.097 2.394-8.303-.45l.003-1.106h.002c-.003-.49.277-.964.795-1.361l-.002.746c-.015 1.284 1.759 2.029 2.943 2.102.522.052 1.061.03 1.577-.06.76-.147 1.548-.44 2.072-1.032.086-.098.16-.206.224-.322.047-.092.102-.208.125-.314.077-.207.047-.702.054-.923.404.38.609.816.582 1.255h.005c-.007.413.042 1.172-.076 1.465Z" fill="#374874"/>
|
||||
<path d="m44.648 24.633 14.216.297c.686.048.415 1.405-.37 1.859L31.834 42.18c-.787.455-1.064-.587-.38-1.425l13.194-16.123Z" fill="#fff"/>
|
||||
<path d="m44.648 24.633 14.216.297c.686.048.415 1.405-.37 1.859L31.834 42.18c-.787.455-1.064-.587-.38-1.425l13.194-16.123Z" fill="#fff"/>
|
||||
<path d="m44.648 24.633 14.216.297c.686.048.415 1.405-.37 1.859L31.834 42.18c-.787.455-1.064-.587-.38-1.425l13.194-16.123Z" fill="#fff"/>
|
||||
<path d="m44.648 24.633 14.216.297c.686.048.415 1.405-.37 1.859L31.834 42.18c-.787.455-1.064-.587-.38-1.425l13.194-16.123Z" fill="#fff"/>
|
||||
<path d="m47.4 40.796-.003 1.118c.042 1.012.865 1.693 1.883 2.1-.202-.412-.345-.834-.348-1.277-.851-.42-1.501-1.06-1.532-1.941Z" fill="#FBDBD0"/>
|
||||
<path d="m48.552 32.529-.019 6.567c-1.56 1.005-1.506 2.553.172 3.522.073.042.15.08.227.119l.002-.046a2.352 2.352 0 0 1 .24-.962c.055-.11.116-.217.18-.323l.068-.114.008-.012c.068-.107.138-.213.213-.316V31.9l-1.091.63Z" fill="#C1DBF6"/>
|
||||
<path d="M56.236 40.845v-.025.014c.006-.497-.236-.995-.726-1.426a11.035 11.035 0 0 0-.442-.037l.03-10.622-5.455 3.15v9.065a5.447 5.447 0 0 0-.47.765c-.174.35-.244.684-.241 1.008.003.443.146.865.348 1.276a7.035 7.035 0 0 0 2.882.463c1.442-.077 3.374-.562 3.979-2.024.147-.339.081-1.222.095-1.607Z" fill="#fff"/>
|
||||
<path d="M56.483 40.709h-.005c0-.65-.368-1.268-1.04-1.765l.03-10.408-.459.264-.028 9.823c-.003.1.008 1.576-.02 1.634a1.214 1.214 0 0 1-.302.569c-.72.727-1.825.931-2.818.927-.722-.02-1.46-.163-2.093-.522-.55-.317-.85-.725-.85-1.148l.022-7.767-.457.264c-.01 3.042-.018 6.202-.018 6.202-1.361.863-1.28 1.726-1.258 3.03.19 3.719 9.84 3.95 9.295-.699-.002-.163-.004-.284 0-.404Zm-.536 1.543c-1.192 2.75-8.043 2.413-8.304-.45l.004-1.105h.001c-.003-.49.277-.965.795-1.362l-.002.747c-.051.898 1.047 1.69 1.969 1.917 1.467.416 3.527.268 4.618-.901.088-.1.164-.21.23-.329.21-.331.179-.858.178-1.237.404.381.609.816.582 1.255h.005c-.007.413.042 1.172-.077 1.465Z" fill="#374874"/>
|
||||
<path d="M13.156 41.491v-9.788l-1.256-.73-.024 8.447c-.752.484-1.132 1.093-1.132 1.705v-.006l-.003 1.118c.051.848.61 1.458 1.375 1.87a3.01 3.01 0 0 1-.007-.261c0-.028.003-.056.004-.084.003-.058.006-.115.012-.171.003-.033.008-.065.013-.098a2.766 2.766 0 0 1 .046-.251l.016-.074a3.96 3.96 0 0 1 .03-.107c.003-.006.004-.013.006-.02v-.002l.005-.014.017-.052c.025-.074.053-.148.084-.22l.028-.062c.03-.065.06-.128.094-.191a3.507 3.507 0 0 1 .173-.293c.031-.047.065-.093.099-.14l.057-.077c.127-.165.269-.323.423-.473l-.052-.023a.218.218 0 0 1-.008-.003Z" fill="#FBDBD0"/>
|
||||
<path d="M18.948 39.823c-.179-.01-.358-.015-.537-.017l.014-5.041-5.268-3.062v9.788l.007.003.052.023c-.436.423-.776.912-.958 1.456a2.885 2.885 0 0 0-.141 1.133c1.15.62 2.767.794 3.844.66 1.409-.152 3.516-.839 3.616-2.505l.003-1.118c0 .018-.004.035-.005.052.016-.474-.192-.951-.627-1.372Z" fill="#C1DBF6"/>
|
||||
<path d="m19.806 41.169.002-.028h-.004c.023-.727-.542-1.473-1.207-1.883l.012-4.386-.456-.265-.015 5.425c-.007.055-.01.135-.023.187l-.015.053a.571.571 0 0 1-.048.134c-.012.027-.023.054-.036.077l-.015.03-.108.16-.055.064-.02.022c-.032.036-.066.07-.1.102l-.02.018c-.083.069-.167.14-.26.197a2.433 2.433 0 0 1-.424.234c-.134.06-.278.112-.418.16a4.876 4.876 0 0 1-.926.188c-.22.021-.453.03-.673.028a5.42 5.42 0 0 1-.999-.117 4.124 4.124 0 0 1-.626-.185l-.024-.009c-.061-.025-.155-.068-.217-.094-.579-.253-1.103-.788-1.072-1.265l.003-.995.022-7.94-.456-.266-.025 8.517c-.698.497-1.085 1.126-1.087 1.785l-.005 1.128c.182 3.318 7.791 3.865 9.181.62.12-.274.123-.74.115-1.318-.002-.148-.004-.28 0-.378Zm-.535 1.517c-1.14 2.747-8.097 2.394-8.303-.45l.004-1.11c0-.426.22-.847.63-1.214-.08.826.705 1.566 1.574 1.886l.024.01c.107.041.225.08.368.124l.031.01c.028.008.06.015.089.022.077.02.174.043.268.066l.056.01a5.978 5.978 0 0 0 .863.099c.028.001.058.004.087.004.207.001.425-.001.632-.02l.033-.002.059-.004.03-.004a5.298 5.298 0 0 0 1.025-.21l.03-.009c.016-.005.03-.01.06-.023a3.63 3.63 0 0 0 .806-.378c.128-.08.252-.175.364-.273l.024-.02a2.2 2.2 0 0 0 .13-.132c.042-.049.09-.096.123-.15a2.079 2.079 0 0 0 .14-.216c.02-.037.036-.075.062-.132a.574.574 0 0 0 .026-.058l.007-.023c.012-.034.02-.067.03-.101l.008-.031a.92.92 0 0 0 .034-.196l.003-.028a.588.588 0 0 0 .006-.066l.001-.25c.449.344.784.886.751 1.37h.003c-.01.415.044 1.198-.078 1.499Z" fill="#374874"/>
|
||||
<path d="m38.215 46.19-.003 1.118c.058.956.76 1.608 1.676 2.017a3.95 3.95 0 0 1-.134-1.189c-.802-.378-1.498-1.054-1.539-1.946Z" fill="#FBDBD0"/>
|
||||
<path d="m39.367 37.832-.019 6.659c-1.7 1.05-1.386 2.801.406 3.644.029-.5.186-.98.505-1.377.187-.25.397-.473.624-.672v-9.13l-1.516.876Z" fill="#C1DBF6"/>
|
||||
<path d="M46.942 45.656c-.163-.427-.515-.834-1.058-1.177l.03-10.427-5.031 2.904v9.13c-.227.2-.437.423-.624.672a2.351 2.351 0 0 0-.506 1.39c-.019.382.033.779.136 1.177 1.118.5 2.558.635 3.544.512 1.408-.151 3.514-.838 3.614-2.504 0 0 0-.569-.004-.933-.002-.155-.053-.617-.101-.744Z" fill="#fff"/>
|
||||
<path d="M47.208 46.07c-.07-.572-.428-1.112-1.028-1.555l.03-10.635-.457.265c-.01 3.375-.032 11.35-.035 11.56-.009.154-.058.31-.135.445-.574.881-1.8 1.141-2.792 1.173-.79.011-1.607-.131-2.3-.521-.55-.317-.851-.725-.85-1.149l.023-7.993-.458.265-.018 6.427c-1.35.847-1.286 1.742-1.259 3.03.21 3.696 9.68 3.956 9.306-.598-.003-.033-.019-.644-.027-.713Zm-.519 1.753c-1.213 2.752-8.018 2.42-8.303-.45l.003-1.106h.002c-.003-.49.277-.964.795-1.362l-.002.747c.107 1.876 2.984 2.35 4.52 2.042.876-.172 1.834-.54 2.295-1.353.027-.051.055-.11.08-.17.02-.05.067-.216.074-.257.04-.237.02-.625.026-.811.3.282.49.595.557.917.023.112.03.338.03.338-.007.413.042 1.171-.077 1.465Z" fill="#374874"/>
|
||||
<path d="M31.562 50.324a.229.229 0 0 1-.228-.228v-7.503a.229.229 0 0 1 .457 0v7.503a.229.229 0 0 1-.229.228ZM33.51 50.682a.229.229 0 0 1-.228-.229v-8.875a.229.229 0 0 1 .457 0v8.875a.229.229 0 0 1-.228.229ZM35.46 50.125a.229.229 0 0 1-.23-.229v-9.56a.229.229 0 1 1 .458 0v9.56a.229.229 0 0 1-.229.229ZM40.883 44.686a.228.228 0 0 1-.229-.228v-7.046a.228.228 0 1 1 .457 0v7.046a.228.228 0 0 1-.228.228ZM42.831 45.044a.228.228 0 0 1-.228-.229v-8.417a.228.228 0 1 1 .457 0v8.417a.228.228 0 0 1-.229.229ZM44.78 44.487a.228.228 0 0 1-.229-.229v-9.103a.228.228 0 1 1 .457 0v9.103a.228.228 0 0 1-.228.229ZM49.887 39.335a.229.229 0 0 1-.229-.229V32.06a.229.229 0 0 1 .458 0v7.046a.229.229 0 0 1-.229.229ZM51.836 39.692a.229.229 0 0 1-.229-.228v-8.418a.228.228 0 1 1 .457 0v8.418a.229.229 0 0 1-.228.228ZM53.784 39.135a.229.229 0 0 1-.229-.228v-9.104a.229.229 0 0 1 .458 0v9.104a.229.229 0 0 1-.229.228Z" fill="#C1DBF6"/>
|
||||
<path d="m44.648 24.633 14.216.297c.686.048.415 1.405-.37 1.859L31.834 42.18c-.787.455-1.064-.587-.38-1.425l13.194-16.123Z" fill="#fff"/>
|
||||
<path d="M36.335 37.94a.379.379 0 0 1-.289-.136.377.377 0 0 1-.004-.49l9.127-11.153a.385.385 0 0 1 .303-.14l9.834.207c.174.003.32.119.362.287a.378.378 0 0 1-.179.425L36.528 37.887a.384.384 0 0 1-.193.053ZM45.5 26.48l-8.843 10.805 18.37-10.606-9.527-.2Z" fill="#C1DBF6"/>
|
||||
<path d="m31.117 42.025-.007-.018a.726.726 0 0 1-.038-.172 1.01 1.01 0 0 1-.003-.169c0-.01 0-.02.002-.029.005-.06.015-.124.03-.189l.005-.017c.017-.07.04-.14.07-.212l.003-.009a1.75 1.75 0 0 1 .113-.223l.004-.007c.046-.076.098-.15.157-.224l13.195-16.123-25.775-14.98L5.678 25.777a1.832 1.832 0 0 0-.13.18l-.027.044-.005.007-.009.015a1.874 1.874 0 0 0-.096.189l-.007.019a1.007 1.007 0 0 0-.004.008l-.015.037a1.628 1.628 0 0 0-.046.14l-.009.035-.004.018-.009.036a1.31 1.31 0 0 0-.018.109l-.004.044a1.022 1.022 0 0 0 .001.198l.004.025a.725.725 0 0 0 .034.147l.008.018a.5.5 0 0 0 .066.12l.008.01c.03.035.062.065.1.088l25.776 14.979a.374.374 0 0 1-.1-.089l-.008-.008a.503.503 0 0 1-.067-.121Z" fill="#FBDBD0"/>
|
||||
<path d="M58.962 24.948a.37.37 0 0 1 .08.035l-25.775-14.98a.368.368 0 0 0-.08-.035h-.003a.415.415 0 0 0-.095-.017l-14.216-.298 25.775 14.98 14.216.297a.457.457 0 0 1 .096.017h.002Z" fill="#C1DBF6"/>
|
||||
<path d="M59.976 42.498c.032-.614-.319-1.193-.947-1.556l-2.148-1.24a3.178 3.178 0 0 0-.896-1.088l.026-9.336 2.94-1.698c.856-.493 1.259-1.495 1.215-2.228-.03-.51-.275-.931-.665-1.158l.001-.002-25.775-14.98-.007-.003-.01-.006a1.323 1.323 0 0 0-.22-.097l-.02-.008-.034-.009a1.333 1.333 0 0 0-.282-.05l-.023-.001h-.023l-14.216-.299-.445-.01-.282.345L4.97 25.198a2.803 2.803 0 0 0-.228.324l-.004.007-.003.005-.006.008-.014.024a2.516 2.516 0 0 0-.143.283l-.014.034-.004.01a2.224 2.224 0 0 0-.111.338l-.006.024-.015.06a2.24 2.24 0 0 0-.042.317 1.923 1.923 0 0 0 .013.37c.017.121.044.233.082.337l.01.027.012.029c.05.122.113.233.188.33l.03.04.019.019c.093.108.202.2.324.27l5.926 3.444-.021 7.458a3.31 3.31 0 0 0-.836 1l-1.94 1.127c-.595.347-.938.882-.94 1.468l-.004 1.362c-.001.593.345 1.134.951 1.484L31.931 59.1a3.319 3.319 0 0 0 1.632.405c.6 0 1.165-.137 1.633-.398l.008-.004.008-.004 23.823-13.845c.136-.08.259-.17.374-.27l.014-.012.013-.013c.344-.325.536-.74.54-1.167V42.498Zm-54.68-15.84c0-.015 0-.03.003-.044.004-.036.01-.072.018-.109l.009-.036.004-.018c.003-.011.005-.023.009-.034a1.515 1.515 0 0 1 .046-.142l.015-.036.004-.008.007-.02a1.755 1.755 0 0 1 .096-.188l.01-.015.004-.007.026-.044a1.867 1.867 0 0 1 .131-.18L18.873 9.653l14.216.298a.49.49 0 0 1 .095.017h.003a.417.417 0 0 1 .077.033l.004.002 25.775 14.98c-.013-.008-.027-.012-.041-.018.502.215.218 1.405-.508 1.824l-.796.46-2.6 1.5-22.984 13.27c-.154.09-.33.144-.508.158a1.189 1.189 0 0 1-.701-.16L11.9 30.973l-6.383-3.71a.375.375 0 0 1-.101-.088l-.008-.008a.5.5 0 0 1-.066-.121l-.008-.018a.721.721 0 0 1-.034-.147l-.004-.025a1.007 1.007 0 0 1-.003-.169l.002-.03Zm53.766 17.127c-.002.2-.111.376-.254.51-.072.063-.15.12-.232.17L34.752 58.309c-.358.2-.78.283-1.19.283h-.067c-.382-.012-.774-.094-1.107-.283L8.65 44.605c-.33-.19-.494-.44-.493-.69l.003-1.36c0-.246.162-.493.486-.68L10.84 40.6c.154-.428.5-.836 1.036-1.18l.022-7.92 18.777 10.913a1.652 1.652 0 0 0 .967.22c.246-.02.488-.095.7-.218l22.755-13.137-.028 9.808c.162.102.313.209.44.322.327.287.54.604.647.93l2.416 1.396c.35.202.512.471.49.736v1.315Z" fill="#374874"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
13
frontend/account/static/src/img/bill.svg
Normal file
13
frontend/account/static/src/img/bill.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M51.0418 14.8189L36.6375 10.6104L34.0749 10.1881C32.413 9.91371 30.7361 10.5834 29.7204 11.9271L26.1265 16.6818L12.884 35.5724L13.9862 36.2129L13.9901 37.5853L27.2922 39.7809L29.8548 40.2032C31.5167 40.4776 33.1936 39.8078 34.2093 38.4642L37.8032 33.7094L51.0457 16.1912L51.0418 14.8189Z" fill="#FBDBD0"/>
|
||||
<path d="M14.0604 62.3678L13.9901 37.5853H13.9901L13.9862 36.2128L12.884 35.5724L12.8879 36.9447H12.8879L12.9582 61.7273L14.0604 62.3678Z" fill="#FBDBD0"/>
|
||||
<path d="M30.5939 40.4924C30.3348 40.4924 30.0737 40.471 29.8176 40.4286L17.3202 38.3665C17.2098 38.3484 17.1288 38.2529 17.1288 38.1411V18.152C17.1288 18.0728 17.1699 17.9991 17.2373 17.9576L44.3426 1.21249C44.3793 1.18973 44.421 1.17834 44.4627 1.17834C44.5011 1.17834 44.5395 1.18794 44.5741 1.20737C44.6464 1.24778 44.6913 1.3241 44.6913 1.40692V24.6036C44.6913 24.6509 44.6766 24.6969 44.6493 24.7355L41.4031 29.3259L34.3918 38.6018C33.4971 39.7855 32.0771 40.4924 30.5939 40.4924Z" fill="white"/>
|
||||
<path d="M44.4627 1.40697V24.6034L41.2166 29.1939L37.8032 33.7093L34.2093 38.464C33.3444 39.6083 32.0002 40.2638 30.5939 40.2638C30.3488 40.2638 30.1015 40.2438 29.8548 40.2031L27.2922 39.7809L21.1462 38.7664L17.3573 38.141V18.1521L44.4627 1.40697ZM44.4627 0.949829C44.3793 0.949829 44.2959 0.972595 44.2224 1.01801L17.117 17.7631C16.9822 17.8465 16.9001 17.9936 16.9001 18.1521V38.141C16.9001 38.3648 17.0621 38.5557 17.2828 38.592L21.0717 39.2174L27.2177 40.2319L29.7805 40.6541C30.0487 40.6984 30.3224 40.7209 30.5939 40.7209C32.1484 40.7209 33.6363 39.9803 34.5739 38.7397L38.1679 33.985L41.5812 29.4696C41.5841 29.4657 41.587 29.4618 41.5898 29.4579L44.8359 24.8673C44.8905 24.7901 44.9198 24.6979 44.9198 24.6034V1.40697C44.9198 1.24123 44.83 1.08843 44.6853 1.00775C44.616 0.969029 44.5393 0.949829 44.4627 0.949829Z" fill="#374874"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.3963 17.4998C35.3964 17.4998 35.3965 17.4999 35.2973 17.7043L35.3965 17.4999C35.5101 17.555 35.5574 17.6918 35.5023 17.8053C35.4472 17.9187 35.3109 17.9661 35.1974 17.9114C35.1974 17.9114 35.1974 17.9114 35.1974 17.9114C35.1974 17.9114 35.1973 17.9113 35.1972 17.9113C35.1972 17.9113 35.1972 17.9113 35.1972 17.9113C35.1972 17.9112 35.1969 17.9111 35.1968 17.9111L35.1959 17.9107C35.1941 17.9098 35.1908 17.9083 35.1861 17.9062C35.1767 17.9021 35.1616 17.8956 35.141 17.8874C35.0997 17.871 35.0367 17.8478 34.9537 17.8228C34.7878 17.7728 34.5429 17.7157 34.235 17.6909C33.6209 17.6414 32.751 17.7198 31.7474 18.2467C30.7281 18.7817 29.4505 19.7833 28.5951 20.9402C27.7344 22.1043 27.348 23.3544 27.9454 24.4431C28.2954 25.0809 28.8051 25.3231 29.4274 25.3727C30.0712 25.4239 30.8164 25.2671 31.6077 25.0997L31.6157 25.0981C32.3858 24.9352 33.2007 24.7628 33.9261 24.8245C34.6752 24.8883 35.3436 25.2029 35.7889 26.0072C36.2881 26.9086 36.2692 27.8633 35.9021 28.7506C35.5375 29.6319 34.8303 30.4479 33.9445 31.1063C32.1784 32.4191 29.6296 33.1565 27.4674 32.4845C27.3469 32.447 27.2795 32.3189 27.317 32.1984C27.3545 32.0778 27.4826 32.0105 27.6031 32.0479C29.5883 32.6649 31.9873 31.9915 33.6718 30.7395C34.5112 30.1155 35.1549 29.3607 35.4797 28.5758C35.8019 27.7971 35.8109 26.9905 35.389 26.2286C35.0305 25.5812 34.5135 25.3333 33.8873 25.28C33.24 25.225 32.4929 25.3798 31.7023 25.547L31.6682 25.5542C30.9064 25.7154 30.1039 25.8851 29.3911 25.8284C28.6462 25.769 27.9824 25.4608 27.5447 24.6631C26.8159 23.3352 27.3317 21.88 28.2275 20.6684C29.1288 19.4495 30.4623 18.405 31.5349 17.8419C32.6232 17.2706 33.581 17.1796 34.2717 17.2353C34.6162 17.263 34.893 17.3271 35.0856 17.3851C35.182 17.4142 35.2574 17.4417 35.3098 17.4626C35.3361 17.473 35.3566 17.4818 35.3711 17.4882C35.3784 17.4914 35.3841 17.494 35.3884 17.496L35.3936 17.4985L35.3954 17.4993L35.396 17.4996L35.3963 17.4998Z" fill="#374874"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.6193 15.9626C31.7455 15.9626 31.8478 16.065 31.8478 16.1912V33.967C31.8478 34.0932 31.7455 34.1956 31.6193 34.1956C31.493 34.1956 31.3907 34.0932 31.3907 33.967V16.1912C31.3907 16.065 31.493 15.9626 31.6193 15.9626Z" fill="#374874"/>
|
||||
<path d="M51.1155 41.1262L51.0457 16.1912L37.8176 33.6978L37.8032 33.6901L34.2093 38.3118C33.1937 39.6555 31.5167 40.3252 29.8548 40.0508L27.2922 39.6285V39.5555L13.9901 37.3598L14.0604 62.5931L51.1155 41.1262Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.4062 39.4304C27.5156 39.4934 27.5533 39.6331 27.4903 39.7425L14.4837 62.3295C14.4208 62.4389 14.281 62.4765 14.1716 62.4135C14.0622 62.3505 14.0246 62.2108 14.0876 62.1014L27.0941 39.5144C27.1571 39.405 27.2969 39.3674 27.4062 39.4304Z" fill="#374874"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.6031 33.5795C37.6642 33.469 37.8032 33.4289 37.9137 33.49L51.2265 40.8467C51.3369 40.9078 51.377 41.0468 51.316 41.1573C51.2549 41.2678 51.1158 41.3079 51.0054 41.2468L37.6926 33.8901C37.5821 33.8291 37.5421 33.69 37.6031 33.5795Z" fill="#374874"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.1834 15.6126C51.2841 15.6887 51.3041 15.8321 51.228 15.9328L34.3916 38.2057C33.3248 39.6172 31.5633 40.3207 29.8176 40.0324C29.8176 40.0324 29.8177 40.0325 29.8176 40.0324L13.9529 37.4146C13.8283 37.394 13.744 37.2764 13.7646 37.1518C13.7851 37.0273 13.9028 36.943 14.0273 36.9635L29.892 39.5814C31.47 39.842 33.0625 39.206 34.0269 37.9301L50.8633 15.6572C50.9394 15.5565 51.0827 15.5365 51.1834 15.6126Z" fill="#374874"/>
|
||||
<path d="M44.4627 1.40696V12.8966L51.0418 14.8187L51.1159 40.9737L14.0603 62.3678L12.9582 61.7273L12.884 35.5724L17.3573 29.1912V18.1521L44.4627 1.40696ZM44.4627 0.492676C44.2958 0.492676 44.1291 0.538321 43.9821 0.629179L16.8767 17.3743C16.6071 17.5408 16.443 17.8351 16.443 18.1521V28.9027L12.1354 35.0476C12.027 35.2021 11.9692 35.3863 11.9697 35.5749L12.0439 61.7298C12.0448 62.0546 12.218 62.3546 12.4988 62.5178L13.601 63.1583C13.743 63.2408 13.9017 63.2821 14.0604 63.2821C14.2182 63.2821 14.376 63.2412 14.5175 63.1595L51.5731 41.7655C51.8567 41.6017 52.0311 41.2987 52.0302 40.9711L51.956 14.8161C51.9549 14.4109 51.6871 14.0547 51.2981 13.9411L45.377 12.2112V1.40696C45.377 1.07549 45.1975 0.76991 44.908 0.608401C44.7694 0.531167 44.6159 0.492676 44.4627 0.492676Z" fill="#374874"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
BIN
frontend/account/static/src/img/btn_paynowcc_lg.gif
Normal file
BIN
frontend/account/static/src/img/btn_paynowcc_lg.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user