Initial commit: Accounting packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:47 +02:00
commit 4ef34c2317
2661 changed files with 1709616 additions and 0 deletions

View file

@ -0,0 +1,84 @@
/** @odoo-module **/
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";
export class AccountMoveController extends FormController {
setup() {
super.setup();
this.account_move_service = useService("account_move");
}
async deleteRecord() {
if ( !await this.account_move_service.addDeletionDialog(this, this.model.root.resId)) {
return super.deleteRecord(...arguments);
}
}
};
export class AccountMoveFormNotebook extends Notebook {
onAnchorClicked(ev) {
if (ev.detail.detail.id === "#outstanding") {
ev.preventDefault();
ev.detail.detail.originalEv.preventDefault();
}
}
async changeTabTo(page_id) {
if (this.props.onBeforeTabSwitch) {
await this.props.onBeforeTabSwitch(page_id);
}
this.state.currentPage = page_id;
}
}
AccountMoveFormNotebook.template = "account.AccountMoveFormNotebook";
AccountMoveFormNotebook.props = {
...Notebook.props,
onBeforeTabSwitch: { type: Function, optional: true },
}
export class AccountMoveFormRenderer extends FormRenderer {
async saveBeforeTabChange() {
if (this.props.record.mode === "edit" && this.props.record.isDirty) {
const contentEl = document.querySelector('.o_content');
const scrollPos = contentEl.scrollTop;
await this.props.record.save({
stayInEdition: true,
});
if (scrollPos) {
contentEl.scrollTop = scrollPos;
}
}
}
}
AccountMoveFormRenderer.components = {
...FormRenderer.components,
AccountMoveFormNotebook: AccountMoveFormNotebook,
}
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", "() => this.saveBeforeTabChange()");
const slots = originalNoteBook.childNodes;
append(noteBook, [...slots]);
return noteBook;
}
}
export const AccountMoveFormView = {
...formView,
Renderer: AccountMoveFormRenderer,
Compiler: AccountMoveFormCompiler,
Controller: AccountMoveController,
};
registry.category("views").add("account_move_form", AccountMoveFormView);

View file

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

View file

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

View file

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="account.AccountPaymentField" owl="1">
<div>
<t t-if="outstanding">
<div>
<strong class="float-start" id="outstanding" t-esc="title"/>
</div>
</t>
<table style="width:100%;">
<t t-foreach="lines" t-as="line" t-key="line_index">
<tr>
<t t-if="outstanding">
<td>
<a title="assign to invoice"
role="button"
class="oe_form_field btn btn-secondary outstanding_credit_assign"
t-att-data-id="line.id"
style="margin-right: 0px; padding-left: 5px; padding-right: 5px;"
href="#"
data-bs-toggle="tooltip"
t-on-click.prevent="() => this.assignOutstandingCredit(line.id)">Add</a>
</td>
<td style="max-width: 11em;">
<a t-att-title="line.date"
role="button"
class="oe_form_field btn btn-link open_account_move"
t-on-click="() => this.openMove(line.move_id)"
style="margin-right: 5px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; padding-left: 0px; width:100%; text-align:left;"
data-bs-toggle="tooltip"
t-att-payment-id="account_payment_id"
t-esc="line.journal_name"/>
</td>
</t>
<t t-if="!outstanding">
<td>
<a role="button" tabindex="0" class="js_payment_info fa fa-info-circle" t-att-index="line_index" style="margin-right:5px;" aria-label="Info" title="Journal Entry Info" data-bs-toggle="tooltip" t-on-click.stop="(ev) => this.onInfoClick(ev, line_index)"></a>
</td>
<td t-if="!line.is_exchange">
<i class="o_field_widget text-start o_payment_label">Paid on <t t-esc="line.date"></t></i>
</td>
<td t-if="line.is_exchange" colspan="2">
<i class="o_field_widget text-start text-muted text-start">
<span class="oe_form_field oe_form_field_float oe_form_field_monetary fw-bold">
<t t-esc="line.amount_formatted"/>
</span>
<span> Exchange Difference</span>
</i>
</td>
</t>
<td t-if="!line.is_exchange" style="text-align:right;">
<span class="oe_form_field oe_form_field_float oe_form_field_monetary" style="margin-left: -10px;">
<t t-esc="line.amount_formatted"/>
</span>
</td>
</tr>
</t>
</table>
</div>
</t>
<t t-name="account.AccountPaymentPopOver" owl="1">
<div class="account_payment_popover">
<h3 t-if="props.title" class="o_popover_header"><t t-esc="props.title"/></h3>
<div class="px-2">
<div>
<table>
<tr>
<td><strong>Amount: </strong></td>
<td>
<t t-esc="props.amount_company_currency"></t>
<t t-if="props.amount_foreign_currency">
(<span class="fa fa-money"/> <t t-esc="props.amount_foreign_currency"/>)
</t>
</td>
</tr>
<tr>
<td><strong>Memo: </strong></td>
<td>
<div style="width: 200px; word-wrap: break-word">
<t t-esc="props.ref"/>
</div>
</td>
</tr>
<tr>
<td><strong>Date: </strong></td>
<td><t t-esc="props.date"/></td>
</tr>
<tr>
<td><strong>Journal: </strong></td>
<td><t t-esc="props.journal_name"/><span t-if="props.payment_method_name"> (<t t-esc="props.payment_method_name"/>)</span></td>
</tr>
</table>
</div>
<button class="btn btn-sm btn-primary js_unreconcile_payment float-start" t-if="!props.is_exchange" style="margin-top:5px; margin-bottom:5px;" groups="account.group_account_invoice" t-on-click="() => props._onRemoveMoveReconcile(props.move_id, props.partial_id)">Unreconcile</button>
<button class="btn btn-sm btn-secondary js_open_payment float-end" style="margin-top:5px; margin-bottom:5px;" t-on-click="() => props._onOpenMove(props.move_id)">View</button>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,93 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { localization } from "@web/core/l10n/localization";
import { parseDate, formatDate } from "@web/core/l10n/dates";
import { formatMonetary } from "@web/views/fields/formatters";
const { Component, onWillUpdateProps } = owl;
class AccountPaymentPopOver extends Component {}
AccountPaymentPopOver.template = "account.AccountPaymentPopOver";
export class AccountPaymentField extends Component {
setup() {
this.popover = usePopover();
this.orm = useService("orm");
this.action = useService("action");
this.formatData(this.props);
onWillUpdateProps((nextProps) => this.formatData(nextProps));
}
formatData(props) {
const info = props.value || {
content: [],
outstanding: false,
title: "",
move_id: this.props.record.data.id,
};
for (let [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.date = formatDate(parseDate(value.date));
}
}
this.lines = info.content;
this.outstanding = info.outstanding;
this.title = info.title;
this.move_id = info.move_id;
}
onInfoClick(ev, idx) {
if (this.popoverCloseFn) {
this.closePopover();
}
this.popoverCloseFn = this.popover.add(
ev.currentTarget,
AccountPaymentPopOver,
{
title: this.env._t("Journal Entry Info"),
...this.lines[idx],
_onRemoveMoveReconcile: this.removeMoveReconcile.bind(this),
_onOpenMove: this.openMove.bind(this),
onClose: this.closePopover,
},
{
position: localization.direction === "rtl" ? "bottom" : "left",
},
);
}
closePopover() {
this.popoverCloseFn();
this.popoverCloseFn = null;
}
async assignOutstandingCredit(id) {
await this.orm.call(this.props.record.resModel, 'js_assign_outstanding_line', [this.move_id, id], {});
await this.props.record.model.root.load();
this.props.record.model.notify();
}
async removeMoveReconcile(moveId, partialId) {
this.closePopover();
await this.orm.call(this.props.record.resModel, 'js_remove_outstanding_partial', [moveId, partialId], {});
await this.props.record.model.root.load();
this.props.record.model.notify();
}
async openMove(moveId) {
const action = await this.orm.call(this.props.record.resModel, 'action_open_business_doc', [moveId], {});
this.action.doAction(action);
}
}
AccountPaymentField.template = "account.AccountPaymentField";
AccountPaymentField.supportedTypes = ["char"];
registry.category("fields").add("payment", AccountPaymentField);

View file

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

View file

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

View file

@ -0,0 +1,23 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { SelectionField } from "@web/views/fields/selection/selection_field";
export class AccountTypeSelection extends SelectionField {
get hierarchyOptions() {
const opts = this.options;
return [
{ name: this.env._t('Balance Sheet') },
{ name: this.env._t('Assets'), children: opts.filter(x => x[0] && x[0].startsWith('asset')) },
{ name: this.env._t('Liabilities'), children: opts.filter(x => x[0] && x[0].startsWith('liability')) },
{ name: this.env._t('Equity'), children: opts.filter(x => x[0] && x[0].startsWith('equity')) },
{ name: this.env._t('Profit & Loss') },
{ name: this.env._t('Income'), children: opts.filter(x => x[0] && x[0].startsWith('income')) },
{ name: this.env._t('Expense'), children: opts.filter(x => x[0] && x[0].startsWith('expense')) },
{ name: this.env._t('Other'), children: opts.filter(x => x[0] && x[0] === 'off_balance') },
];
}
}
AccountTypeSelection.template = "account.AccountTypeSelection";
registry.category("fields").add("account_type_selection", AccountTypeSelection);

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="account.AccountTypeSelection" t-inherit="web.SelectionField" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-foreach='options']" position="replace">
<t t-foreach="hierarchyOptions" t-as="group" t-key="group_index">
<optgroup t-att-label="group.name">
<t t-if="!!group.children">
<t t-foreach="group.children" t-as="child" t-key="child[0]">
<option
t-att-selected="child[0] === value"
t-att-value="stringify(child[0])"
t-esc="child[1]"/>
</t>
</t>
</optgroup>
</t>
</xpath>
</t>
</templates>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.GroupedListTemplate" owl="1">
<table t-if="data.groups_vals.length" class="table table-sm o_list_table table table-sm table-hover table-striped o_list_table_grouped">
<thead><tr>
<t t-foreach="data.options.columns" t-as="col" t-key="col_index">
<th t-esc="col['label']" t-attf-class="{{col['class']}}"/>
</t>
</tr></thead>
<t t-foreach="data.groups_vals" t-as="group_vals" t-key="group_vals_index">
<ListGroup group_vals="group_vals" options="data.options"/>
</t>
</table>
<t t-if="data.options.discarded_number">
<span><t t-esc="data.options.discarded_number"/> are not shown in the preview</span>
</t>
</t>
<tbody t-name="account.GroupedItemsTemplate" owl="1">
<tr style="background-color: #dee2e6;">
<td t-attf-colspan="{{props.options.columns.length}}">
<t t-esc="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" owl="1">
<t t-foreach="props.options.columns" t-as="col" t-key="col_index">
<td t-esc="props.item_vals[col['field']]" t-attf-class="{{col['class']}}"/>
</t>
</tr>
<t t-name="account.OpenMoveTemplate">
<a href="#" t-esc="widget.value"/>
</t>
</templates>

View file

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

View file

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

View file

@ -0,0 +1,17 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2OneField } from "@web/views/fields/many2one/many2one_field";
class LineOpenMoveWidget extends Many2OneField {
async openAction() {
this.action.doActionButton({
type: "object",
resId: this.props.value[0],
name: "action_open_business_doc",
resModel: "account.move.line",
});
}
}
registry.category("fields").add("line_open_move_widget", LineOpenMoveWidget);

View file

@ -0,0 +1,25 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const { Component } = owl;
class OpenMoveWidget extends Component {
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: "account.move.line",
});
}
}
OpenMoveWidget.template = "account.OpenMoveWidget";
registry.category("fields").add("open_move_widget", OpenMoveWidget);

View file

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

View file

@ -0,0 +1,35 @@
// The goal of this file is to contain CSS hacks related to allowing
// section and note on sale order and invoice.
table.o_section_and_note_list_view tr.o_data_row.o_is_line_note,
table.o_section_and_note_list_view tr.o_data_row.o_is_line_note textarea[name="name"],
div.oe_kanban_card.o_is_line_note {
font-style: italic;
}
table.o_section_and_note_list_view tr.o_data_row.o_is_line_section,
div.oe_kanban_card.o_is_line_section {
font-weight: bold;
background-color: #DDDDDD;
}
table.o_section_and_note_list_view tr.o_data_row.o_is_line_section {
border-top: 1px solid #BBB;
border-bottom: 1px solid #BBB;
}
table.o_section_and_note_list_view tr.o_data_row {
&.o_is_line_note,
&.o_is_line_section {
td {
// There is an undeterministic CSS behaviour in Chrome related to
// the combination of the row's and its children's borders.
border: none !important;
}
}
}
.o_field_section_and_note_text {
> span {
white-space: pre-wrap !important;
}
}

View file

@ -0,0 +1,98 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { ListRenderer } from "@web/views/list/list_renderer";
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
import { TextField, ListTextField } from "@web/views/fields/text/text_field";
import { CharField } from "@web/views/fields/char/char_field";
const { Component, useEffect } = owl;
export class SectionAndNoteListRenderer extends ListRenderer {
/**
* 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";
useEffect(
() => this.focusToName(this.props.list.editedRecord),
() => [this.props.list.editedRecord]
)
}
focusToName(editRec) {
if (editRec && editRec.isVirtual && this.isSectionOrNote(editRec)) {
const col = this.state.columns.find((c) => c.name === this.titleField);
this.focusCell(col, null);
}
}
isSectionOrNote(record=null) {
record = record || this.record;
return ['line_section', 'line_note'].includes(record.data.display_type);
}
getRowClass(record) {
const existingClasses = super.getRowClass(record);
return `${existingClasses} o_is_${record.data.display_type}`;
}
getCellClass(column, record) {
const classNames = super.getCellClass(column, record);
if (this.isSectionOrNote(record) && column.widget !== "handle" && column.name !== this.titleField) {
return `${classNames} o_hidden`;
}
return classNames;
}
getColumns(record) {
const columns = super.getColumns(record);
if (this.isSectionOrNote(record)) {
return this.getSectionColumns(columns);
}
return columns;
}
getSectionColumns(columns) {
const sectionCols = columns.filter((col) => col.widget === "handle" || col.type === "field" && col.name === this.titleField);
return sectionCols.map((col) => {
if (col.name === this.titleField) {
return { ...col, colspan: columns.length - sectionCols.length + 1 };
} else {
return { ...col };
}
});
}
}
SectionAndNoteListRenderer.template = "account.sectionAndNoteListRenderer";
export class SectionAndNoteFieldOne2Many extends X2ManyField {}
SectionAndNoteFieldOne2Many.additionalClasses = ['o_field_one2many'];
SectionAndNoteFieldOne2Many.components = {
...X2ManyField.components,
ListRenderer: SectionAndNoteListRenderer,
};
export class SectionAndNoteText extends Component {
get componentToUse() {
return this.props.record.data.display_type === 'line_section' ? CharField : TextField;
}
}
SectionAndNoteText.template = "account.SectionAndNoteText";
SectionAndNoteText.additionalClasses = ["o_field_text"];
export class ListSectionAndNoteText extends SectionAndNoteText {
get componentToUse() {
return this.props.record.data.display_type !== "line_section"
? ListTextField
: super.componentToUse;
}
}
registry.category("fields").add("section_and_note_one2many", SectionAndNoteFieldOne2Many);
registry.category("fields").add("section_and_note_text", SectionAndNoteText);
registry.category("fields").add("list.section_and_note_text", ListSectionAndNoteText);

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.sectionAndNoteListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary" owl="1">
<xpath expr="//table" position="attributes">
<attribute name="class" position="add" separator=" ">o_section_and_note_list_view</attribute>
</xpath>
</t>
<t t-name="account.SectionAndNoteText" owl="1">
<t t-component="componentToUse" t-props="props"/>
</t>
</templates>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,187 @@
/** @odoo-module **/
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
import { parseFloat } from "@web/views/fields/parsers";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import { useNumpadDecimal } from "@web/views/fields/numpad_decimal_hook";
const { Component, onPatched, onWillUpdateProps, useRef, useState } = owl;
/**
A line of some TaxTotalsComponent, giving the values of a tax group.
**/
class TaxGroupComponent extends Component {
setup() {
this.inputTax = useRef("taxValueInput");
this.state = useState({ value: "readonly" });
onPatched(() => {
if (this.state.value === "edit") {
const { taxGroup, currency } = this.props;
const newVal = formatFloat(taxGroup.tax_group_amount, { digits: (currency && currency.digits) });
this.inputTax.el.value = newVal;
this.inputTax.el.focus(); // Focus the input
}
});
onWillUpdateProps(() => {
this.setState("readonly");
});
useNumpadDecimal();
}
//--------------------------------------------------------------------------
// 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_group_amount;
let newValue;
try {
newValue = parseFloat(this.inputTax.el.value); // Get the new value
} catch (_err) {
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;
}
this.props.taxGroup.tax_group_amount = newValue;
this.props.onChangeTaxGroup({
oldValue,
newValue: newValue,
taxGroupId: this.props.taxGroup.tax_group_id,
});
}
}
TaxGroupComponent.props = {
currency: { optional: true },
taxGroup: { optional: true },
onChangeTaxGroup: { optional: true },
isReadonly: Boolean,
invalidate: Function,
};
TaxGroupComponent.template = "account.TaxGroupComponent";
/**
Widget used to display tax totals by tax groups for invoices, PO and SO,
and possibly allowing editing them.
Note that this widget requires the object it is used on to have a
currency_id field.
**/
export class TaxTotalsComponent extends Component {
setup() {
this.totals = {};
this.formatData(this.props);
onWillUpdateProps((nextProps) => {
this.formatData(nextProps);
});
}
get readonly() {
return this.props.readonly;
}
get currencyId() {
const recordCurrency = this.props.record.data.currency_id;
return recordCurrency && recordCurrency[0];
}
get currency() {
return session.currencies[this.currencyId];
}
invalidate() {
return this.props.record.setInvalidField(this.props.name);
}
/**
* 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.update(this.totals);
this.totals.display_rounding = false;
}
formatData(props) {
let totals = JSON.parse(JSON.stringify(props.value));
const currencyFmtOpts = { currencyId: props.record.data.currency_id && props.record.data.currency_id[0] };
let amount_untaxed = totals.amount_untaxed;
let amount_tax = 0;
let subtotals = [];
for (let subtotal_title of totals.subtotals_order) {
let amount_total = amount_untaxed + amount_tax;
subtotals.push({
'name': subtotal_title,
'amount': amount_total,
'formatted_amount': formatMonetary(amount_total, currencyFmtOpts),
});
let group = totals.groups_by_subtotal[subtotal_title];
for (let i in group) {
amount_tax = amount_tax + group[i].tax_group_amount;
}
}
totals.subtotals = subtotals;
let rounding_amount = totals.display_rounding && totals.rounding_amount || 0;
let amount_total = amount_untaxed + amount_tax + rounding_amount;
totals.amount_total = amount_total;
totals.formatted_amount_total = formatMonetary(amount_total, currencyFmtOpts);
for (let group_name of Object.keys(totals.groups_by_subtotal)) {
let group = totals.groups_by_subtotal[group_name];
for (let key in group) {
group[key].formatted_tax_group_amount = formatMonetary(group[key].tax_group_amount, currencyFmtOpts);
group[key].formatted_tax_group_base_amount = formatMonetary(group[key].tax_group_base_amount, currencyFmtOpts);
}
}
this.totals = totals;
}
}
TaxTotalsComponent.template = "account.TaxTotalsField";
TaxTotalsComponent.components = { TaxGroupComponent };
TaxTotalsComponent.props = {
...standardFieldProps,
};
registry.category("fields").add("account-tax-totals-field", TaxTotalsComponent);

View file

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.TaxGroupComponent" owl="1">
<tr>
<td class="o_td_label">
<label class="o_form_label o_tax_total_label" t-esc="props.taxGroup.tax_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="props.taxGroup.formatted_tax_group_amount"/>
</span>
</span>
</t>
</t>
<t t-else="">
<span class="o_tax_group_amount_value o_list_monetary">
<t t-out="props.taxGroup.formatted_tax_group_amount" style="white-space: nowrap;"/>
</span>
</t>
</td>
</tr>
</t>
<t t-name="account.TaxTotalsField" owl="1">
<table t-if="totals" class="oe_right">
<tbody>
<t t-foreach="totals.subtotals" t-as="subtotal" t-key="subtotal['name']">
<tr>
<td class="o_td_label">
<label class="o_form_label o_tax_total_label" t-esc="subtotal['name']"/>
</td>
<td class="o_list_monetary">
<span t-att-name="subtotal['name']" style="white-space: nowrap; font-weight: bold;" t-out="subtotal['formatted_amount']"/>
</td>
</tr>
<t t-foreach="totals.groups_by_subtotal[subtotal['name']]" t-as="taxGroup" t-key="taxGroup.group_key">
<TaxGroupComponent
currency="currency"
taxGroup="taxGroup"
isReadonly="readonly"
onChangeTaxGroup.bind="_onChangeTaxValueByTaxGroup"
invalidate.bind="invalidate"
/>
</t>
</t>
<tr t-if="'formatted_rounding_amount' in totals and totals.rounding_amount !== 0 and totals.display_rounding">
<td class="o_td_label">
<label class="o_form_label o_tax_total_label">Rounding</label>
</td>
<td class="o_list_monetary">
<span
t-out="totals.formatted_rounding_amount"
/>
</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="Object.keys(totals.groups_by_subtotal).length > 0 ? 'oe_subtotal_footer_separator' : ''"
t-out="totals.formatted_amount_total"
style="font-size: 1.3em; font-weight: bold; white-space: nowrap;"
/>
</td>
</tr>
</tbody>
</table>
</t>
</templates>