Initial commit
This commit is contained in:
6
Fusion Accounting/static/csv/account.bank.statement.csv
Normal file
6
Fusion Accounting/static/csv/account.bank.statement.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
Date,Reference,Partner,Label,Amount,Amount Currency,Currency,Cumulative Balance
|
||||
2017-05-10,INV/2017/0001,,#01,4610,,,4710
|
||||
2017-05-11,Payment bill 20170521,,#02,-100,,,4610
|
||||
2017-05-15,INV/2017/0003 discount 2% early payment,,#03,514.5,,,5124.5
|
||||
2017-05-30,INV/2017/0002 + INV/2017/0004,,#04,5260,,,10384.5
|
||||
2017-05-31,Payment bill EUR 001234565,,#05,-537.15,-500,EUR,9847.35
|
||||
|
6
Fusion Accounting/static/csv/account.bank.statement2.csv
Normal file
6
Fusion Accounting/static/csv/account.bank.statement2.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
Journal,Name,Date,Starting Balance,Ending Balance,Statement lines / Date,Statement lines / Label,Statement lines / Partner,Statement lines / Reference,Statement lines / Amount,Statement lines / Amount Currency,Statement lines / Currency
|
||||
Bank,Statement May 01,2017-05-15,100,5124.5,2017-05-10,INV/2017/0001,,#01,4610,,
|
||||
,,,,,2017-05-11,Payment bill 20170521,,#02,-100,,
|
||||
,,,,,2017-05-15,INV/2017/0003 discount 2% early payment,,#03,514.5,,
|
||||
Bank,Statement May 02,2017-05-30,5124.5,9847.35,2017-05-30,INV/2017/0002 + INV/2017/0004,,#01,5260,,
|
||||
,,,,,2017-05-31,Payment bill EUR 001234565,,#02,-537.15,-500,EUR
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
Fusion Accounting/static/description/icon.png
Normal file
BIN
Fusion Accounting/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,23 @@
|
||||
// Fusion Accounting - Bank Statement Import Model
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { BaseImportModel } from "@base_import/import_model";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
/**
|
||||
* Patches BaseImportModel to add a bank statement CSV import template
|
||||
* when importing account.bank.statement records.
|
||||
*/
|
||||
patch(BaseImportModel.prototype, {
|
||||
async init() {
|
||||
await super.init(...arguments);
|
||||
|
||||
if (this.resModel === "account.bank.statement") {
|
||||
this.importTemplates.push({
|
||||
label: _t("Import Template for Bank Statements"),
|
||||
template: "/fusion_accounting/static/csv/account.bank.statement2.csv",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Fusion Accounting - Bank Reconciliation Finish Buttons (Upload Extension)
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader";
|
||||
import { BankRecFinishButtons } from "@fusion_accounting/components/bank_reconciliation/finish_buttons";
|
||||
|
||||
/**
|
||||
* Patches BankRecFinishButtons to add the file upload component
|
||||
* for importing bank statements directly from the finish screen.
|
||||
*/
|
||||
patch(BankRecFinishButtons, {
|
||||
components: {
|
||||
AccountFileUploader,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="fusion_accounting.BankRecFinishButtons" t-inherit="fusion_accounting.BankRecFinishButtons" t-inherit-mode="extension">
|
||||
<xpath expr="//p" position="before">
|
||||
<div class="mb-1" t-call="account.AccountViewUploadButton"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
48
Fusion Accounting/static/src/bank_reconciliation/kanban.js
Normal file
48
Fusion Accounting/static/src/bank_reconciliation/kanban.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Fusion Accounting - Bank Reconciliation Kanban Upload
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader";
|
||||
import { UploadDropZone } from "@account/components/upload_drop_zone/upload_drop_zone";
|
||||
import { BankRecKanbanView, BankRecKanbanController, BankRecKanbanRenderer } from "@fusion_accounting/components/bank_reconciliation/kanban";
|
||||
import { useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* BankRecKanbanUploadController - Extends the kanban controller with
|
||||
* file upload support for importing bank statements via drag-and-drop.
|
||||
*/
|
||||
export class BankRecKanbanUploadController extends BankRecKanbanController {
|
||||
static components = {
|
||||
...BankRecKanbanController.components,
|
||||
AccountFileUploader,
|
||||
}
|
||||
}
|
||||
|
||||
export class BankRecUploadKanbanRenderer extends BankRecKanbanRenderer {
|
||||
static template = "account.BankRecKanbanUploadRenderer";
|
||||
static components = {
|
||||
...BankRecKanbanRenderer.components,
|
||||
UploadDropZone,
|
||||
};
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dropzoneState = useState({
|
||||
visible: false,
|
||||
});
|
||||
}
|
||||
|
||||
onDragStart(ev) {
|
||||
if (ev.dataTransfer.types.includes("Files")) {
|
||||
this.dropzoneState.visible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BankRecKanbanUploadView = {
|
||||
...BankRecKanbanView,
|
||||
Controller: BankRecKanbanUploadController,
|
||||
Renderer: BankRecUploadKanbanRenderer,
|
||||
buttonTemplate: "account.BankRecKanbanButtons",
|
||||
};
|
||||
|
||||
registry.category("views").add('bank_rec_widget_kanban', BankRecKanbanUploadView, { force: true });
|
||||
19
Fusion Accounting/static/src/bank_reconciliation/kanban.xml
Normal file
19
Fusion Accounting/static/src/bank_reconciliation/kanban.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="account.BankRecKanbanButtons">
|
||||
<xpath expr="//div[hasclass('o_cp_buttons')]" position="inside">
|
||||
<t t-call="account.AccountViewUploadButton"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="account.BankRecKanbanUploadRenderer" t-inherit="account.BankRecKanbanRenderer" t-inherit-mode="primary">
|
||||
<xpath expr="//div[@t-ref='root']" position="before">
|
||||
<UploadDropZone
|
||||
visible="dropzoneState.visible"
|
||||
hideZone="() => dropzoneState.visible = false"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@t-ref='root']" position="attributes">
|
||||
<attribute name="t-on-dragenter.stop.prevent">onDragStart</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
48
Fusion Accounting/static/src/bank_reconciliation/list.js
Normal file
48
Fusion Accounting/static/src/bank_reconciliation/list.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Fusion Accounting - Bank Reconciliation List Upload
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader";
|
||||
import { UploadDropZone } from "@account/components/upload_drop_zone/upload_drop_zone";
|
||||
import { bankRecListView, BankRecListController } from "@fusion_accounting/components/bank_reconciliation/list";
|
||||
import { useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* BankRecListUploadController - Extends the list controller with file
|
||||
* upload capabilities for importing bank statements via drag-and-drop.
|
||||
*/
|
||||
export class BankRecListUploadController extends BankRecListController {
|
||||
static components = {
|
||||
...BankRecListController.components,
|
||||
AccountFileUploader,
|
||||
}
|
||||
}
|
||||
|
||||
export class BankRecListUploadRenderer extends ListRenderer {
|
||||
static template = "account.BankRecListUploadRenderer";
|
||||
static components = {
|
||||
...ListRenderer.components,
|
||||
UploadDropZone,
|
||||
}
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dropzoneState = useState({ visible: false });
|
||||
}
|
||||
|
||||
onDragStart(ev) {
|
||||
if (ev.dataTransfer.types.includes("Files")) {
|
||||
this.dropzoneState.visible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bankRecListUploadView = {
|
||||
...bankRecListView,
|
||||
Controller: BankRecListUploadController,
|
||||
Renderer: BankRecListUploadRenderer,
|
||||
buttonTemplate: "account.BankRecListUploadButtons",
|
||||
}
|
||||
|
||||
registry.category("views").add("bank_rec_list", bankRecListUploadView, { force: true });
|
||||
19
Fusion Accounting/static/src/bank_reconciliation/list.xml
Normal file
19
Fusion Accounting/static/src/bank_reconciliation/list.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="account.BankRecListUploadButtons" t-inherit="web.ListView.Buttons" t-inherit-mode="primary">
|
||||
<xpath expr="//div[hasclass('o_list_buttons')]" position="inside">
|
||||
<t t-call="account.AccountViewUploadButton"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="account.BankRecListUploadRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
|
||||
<xpath expr="//div[@t-ref='root']" position="before">
|
||||
<UploadDropZone
|
||||
visible="dropzoneState.visible"
|
||||
hideZone="() => dropzoneState.visible = false"/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@t-ref='root']" position="attributes">
|
||||
<attribute name="t-on-dragenter.stop.prevent">onDragStart</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,53 @@
|
||||
// Fusion Accounting - Bank Statement CSV Import Action
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ImportAction } from "@base_import/import_action/import_action";
|
||||
import { useBankStatementCSVImportModel } from "./bank_statement_csv_import_model";
|
||||
|
||||
/**
|
||||
* BankStatementImportAction - Client action for importing bank statements
|
||||
* from CSV files. Extends the base import action with journal-specific
|
||||
* configuration and post-import navigation.
|
||||
*/
|
||||
export class BankStatementImportAction extends ImportAction {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.action = useService("action");
|
||||
|
||||
this.model = useBankStatementCSVImportModel({
|
||||
env: this.env,
|
||||
resModel: this.resModel,
|
||||
context: this.props.action.params.context || {},
|
||||
orm: this.orm,
|
||||
});
|
||||
|
||||
this.env.config.setDisplayName(_t("Import Bank Statement")); // Displayed in the breadcrumbs
|
||||
this.state.filename = this.props.action.params.filename || undefined;
|
||||
|
||||
onWillStart(async () => {
|
||||
if (this.props.action.params.context) {
|
||||
this.model.id = this.props.action.params.context.wizard_id;
|
||||
await super.handleFilesUpload([{ name: this.state.filename }])
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async exit() {
|
||||
if (this.model.statement_id) {
|
||||
const res = await this.orm.call(
|
||||
"account.bank.statement",
|
||||
"action_open_bank_reconcile_widget",
|
||||
[this.model.statement_id]
|
||||
);
|
||||
return this.action.doAction(res);
|
||||
}
|
||||
super.exit();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("import_bank_stmt", BankStatementImportAction);
|
||||
@@ -0,0 +1,43 @@
|
||||
// Fusion Accounting - Bank Statement CSV Import Model
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { useState } from "@odoo/owl";
|
||||
import { BaseImportModel } from "@base_import/import_model";
|
||||
|
||||
/**
|
||||
* BankStatementCSVImportModel - Import model for bank statement CSV files.
|
||||
* Handles journal selection and CSV parsing configuration for
|
||||
* streamlined bank statement data import.
|
||||
*/
|
||||
class BankStatementCSVImportModel extends BaseImportModel {
|
||||
async init() {
|
||||
this.importOptionsValues.bank_stmt_import = {
|
||||
value: true,
|
||||
};
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async _onLoadSuccess(res) {
|
||||
super._onLoadSuccess(res);
|
||||
|
||||
if (!res.messages || res.messages.length === 0 || res.messages.length > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = res.messages[0];
|
||||
if (message.ids) {
|
||||
this.statement_line_ids = message.ids
|
||||
}
|
||||
|
||||
if (message.messages && message.messages.length > 0) {
|
||||
this.statement_id = message.messages[0].statement_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {BankStatementCSVImportModel}
|
||||
*/
|
||||
export function useBankStatementCSVImportModel({ env, resModel, context, orm }) {
|
||||
return useState(new BankStatementCSVImportModel({ env, resModel, context, orm }));
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Fusion Accounting - Account Report Component
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { ControlPanel } from "@web/search/control_panel/control_panel";
|
||||
|
||||
import { Component, onWillStart, useState, useSubEnv } from "@odoo/owl";
|
||||
|
||||
import { AccountReportController } from "@fusion_accounting/components/account_report/controller";
|
||||
import { AccountReportButtonsBar } from "@fusion_accounting/components/account_report/buttons_bar/buttons_bar";
|
||||
import { AccountReportCogMenu } from "@fusion_accounting/components/account_report/cog_menu/cog_menu";
|
||||
import { AccountReportEllipsis } from "@fusion_accounting/components/account_report/ellipsis/ellipsis";
|
||||
import { AccountReportFilters } from "@fusion_accounting/components/account_report/filters/filters";
|
||||
import { AccountReportHeader } from "@fusion_accounting/components/account_report/header/header";
|
||||
import { AccountReportLine } from "@fusion_accounting/components/account_report/line/line";
|
||||
import { AccountReportLineCell } from "@fusion_accounting/components/account_report/line_cell/line_cell";
|
||||
import { AccountReportLineName } from "@fusion_accounting/components/account_report/line_name/line_name";
|
||||
import { AccountReportSearchBar } from "@fusion_accounting/components/account_report/search_bar/search_bar";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
import { useSetupAction } from "@web/search/action_hook";
|
||||
|
||||
/**
|
||||
* AccountReport - Main client action component for rendering financial reports.
|
||||
* Manages report lifecycle, sub-components, and user interactions such as
|
||||
* folding/unfolding lines, pagination, and custom component registration.
|
||||
*/
|
||||
export class AccountReport extends Component {
|
||||
static template = "fusion_accounting.AccountReport";
|
||||
static props = { ...standardActionServiceProps };
|
||||
static components = {
|
||||
ControlPanel,
|
||||
AccountReportButtonsBar,
|
||||
AccountReportCogMenu,
|
||||
AccountReportSearchBar,
|
||||
};
|
||||
|
||||
static customizableComponents = [
|
||||
AccountReportEllipsis,
|
||||
AccountReportFilters,
|
||||
AccountReportHeader,
|
||||
AccountReportLine,
|
||||
AccountReportLineCell,
|
||||
AccountReportLineName,
|
||||
];
|
||||
static defaultComponentsMap = [];
|
||||
|
||||
setup() {
|
||||
|
||||
useSetupAction({
|
||||
getLocalState: () => {
|
||||
return {
|
||||
keep_journal_groups_options: true, // used when using the breadcrumb
|
||||
};
|
||||
}
|
||||
})
|
||||
if (this.props?.state?.keep_journal_groups_options !== undefined) {
|
||||
this.props.action.keep_journal_groups_options = true;
|
||||
}
|
||||
|
||||
// Can not use 'control-panel-bottom-right' slot without this, as viewSwitcherEntries doesn't exist here.
|
||||
this.env.config.viewSwitcherEntries = [];
|
||||
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.controller = useState(new AccountReportController(this.props.action));
|
||||
this.initialQuery = this.props.action.context?.default_filter_accounts;
|
||||
|
||||
for (const customizableComponent of AccountReport.customizableComponents)
|
||||
AccountReport.defaultComponentsMap[customizableComponent.name] = customizableComponent;
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.controller.load(this.env);
|
||||
});
|
||||
|
||||
useSubEnv({
|
||||
controller: this.controller,
|
||||
component: this.getComponent.bind(this),
|
||||
template: this.getTemplate.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Custom overrides
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
static registerCustomComponent(customComponent) {
|
||||
registry.category("fusion_accounting_custom_components").add(customComponent.template, customComponent);
|
||||
}
|
||||
|
||||
get cssCustomClass() {
|
||||
return this.controller.data.custom_display.css_custom_class || "";
|
||||
}
|
||||
|
||||
getComponent(name) {
|
||||
const customComponents = this.controller.data.custom_display.components;
|
||||
|
||||
if (customComponents && customComponents[name])
|
||||
return registry.category("fusion_accounting_custom_components").get(customComponents[name]);
|
||||
|
||||
return AccountReport.defaultComponentsMap[name];
|
||||
}
|
||||
|
||||
getTemplate(name) {
|
||||
const customTemplates = this.controller.data.custom_display.templates;
|
||||
|
||||
if (customTemplates && customTemplates[name])
|
||||
return customTemplates[name];
|
||||
|
||||
return `fusion_accounting.${ name }Customizable`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Table
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get tableClasses() {
|
||||
let classes = "";
|
||||
|
||||
if (this.controller.options.columns.length > 1) {
|
||||
classes += " striped";
|
||||
}
|
||||
|
||||
if (this.controller.options['horizontal_split'])
|
||||
classes += " w-50 mx-2";
|
||||
|
||||
return classes;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("account_report", AccountReport);
|
||||
@@ -0,0 +1,456 @@
|
||||
.account_report {
|
||||
.fit-content { width: fit-content }
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Control panel
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.o_control_panel_main_buttons {
|
||||
.dropdown-item {
|
||||
padding: 0;
|
||||
.btn-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 3px 20px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Sections
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.section_selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 16px 16px 8px 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Alert
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.warnings { margin-bottom: 1rem }
|
||||
.alert {
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
|
||||
a:hover { cursor:pointer }
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// No content
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.o_view_nocontent { z-index: -1 }
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Table
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.table {
|
||||
background-color: $o-view-background-color;
|
||||
border-collapse: separate; //!\\ Allows to add padding to the table
|
||||
border-spacing: 0; //!\\ Removes default spacing between cells due to 'border-collapse: separate'
|
||||
font-size: 0.8rem;
|
||||
margin: 0 auto 24px;
|
||||
padding: 24px;
|
||||
width: auto;
|
||||
min-width: 800px;
|
||||
border: 1px solid $o-gray-300;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
> :not(caption) > * > * { padding: 0.25rem 0.75rem } //!\\ Override of bootstrap, keep selector
|
||||
|
||||
> thead {
|
||||
> tr {
|
||||
th:first-child {
|
||||
color: lightgrey;
|
||||
}
|
||||
th:not(:first-child) {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
> tr:not(:last-child) > th:not(:first-child) { border: 1px solid $o-gray-300 }
|
||||
}
|
||||
|
||||
> tbody {
|
||||
> tr {
|
||||
&.unfolded { font-weight: bold }
|
||||
> td {
|
||||
a { cursor: pointer }
|
||||
.clickable { color: $o-action }
|
||||
&.muted { color: var(--AccountReport-muted-data-color, $o-gray-300) }
|
||||
&:empty::after{ content: "\00a0" } //!\\ Prevents the collapse of empty table rows
|
||||
&:empty { line-height: 1 }
|
||||
.btn_annotation { color: $o-action }
|
||||
}
|
||||
|
||||
&:not(.empty) > td { border-bottom: 1px solid var(--AccountReport-fine-line-separator-color, $o-gray-200) }
|
||||
&.total { font-weight: bold }
|
||||
&.o_bold_tr { font-weight: bold }
|
||||
|
||||
&.unfolded {
|
||||
> td { border-bottom: 1px solid $o-gray-300 }
|
||||
.btn_action { opacity: 1 }
|
||||
.btn_more { opacity: 1 }
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&.empty > * { --table-accent-bg: transparent }
|
||||
.auditable {
|
||||
color: $o-action !important;
|
||||
|
||||
> a:hover { cursor: pointer }
|
||||
}
|
||||
.muted { color: $o-gray-800 }
|
||||
.btn_action, .btn_more {
|
||||
opacity: 1;
|
||||
color: $o-action;
|
||||
}
|
||||
.btn_edit { color: $o-action }
|
||||
.btn_dropdown { color: $o-action }
|
||||
.btn_foldable { color: $o-action }
|
||||
.btn_ellipsis { color: $o-action }
|
||||
.btn_annotation_go { color: $o-action }
|
||||
.btn_debug { color: $o-action }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.striped {
|
||||
//!\\ Changes the background of every even column starting with the 3rd one
|
||||
> thead > tr:not(:first-child) > th:nth-child(2n+3) { background: $o-gray-100 }
|
||||
> tbody {
|
||||
> tr:not(.line_level_0):not(.empty) > td:nth-child(2n+3) { background: $o-gray-100 }
|
||||
> tr.line_level_0 > td:nth-child(2n+3) { background: $o-gray-300 }
|
||||
}
|
||||
}
|
||||
|
||||
thead.sticky {
|
||||
background-color: $o-view-background-color;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Line
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.line_name {
|
||||
> .wrapper {
|
||||
display: flex;
|
||||
|
||||
> .content {
|
||||
display: flex;
|
||||
sup { top: auto }
|
||||
}
|
||||
}
|
||||
|
||||
.name { white-space: nowrap }
|
||||
&.unfoldable:hover { cursor: pointer }
|
||||
}
|
||||
|
||||
.line_cell {
|
||||
> .wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> .content { display: flex }
|
||||
}
|
||||
|
||||
&.date > .wrapper { justify-content: center }
|
||||
&.numeric > .wrapper { justify-content: flex-end }
|
||||
.name { white-space: nowrap }
|
||||
}
|
||||
|
||||
.editable-cell {
|
||||
input {
|
||||
color: $o-action;
|
||||
border: none;
|
||||
max-width: 100px;
|
||||
float: right;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-bottom-color: $o-action !important;
|
||||
|
||||
input {
|
||||
color: $o-black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.line_level_0 {
|
||||
color: $o-gray-700;
|
||||
font-weight: bold;
|
||||
|
||||
> td {
|
||||
border-bottom: 0 !important;
|
||||
background-color: $o-gray-300;
|
||||
}
|
||||
.muted { color: $o-gray-400 !important }
|
||||
.btn_debug { color: $o-gray-400 }
|
||||
}
|
||||
|
||||
@for $i from 2 through 16 {
|
||||
.line_level_#{$i} {
|
||||
$indentation: (($i + 1) * 8px) - 20px; // 20px are for the btn_foldable width
|
||||
|
||||
> td {
|
||||
color: $o-gray-700;
|
||||
|
||||
&.line_name.unfoldable .wrapper { column-gap: calc(#{ $indentation }) }
|
||||
&.line_name:not(.unfoldable) .wrapper { padding-left: $indentation }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Link
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.link { color: $o-action }
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// buttons
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.btn_debug, .btn_dropdown, .btn_foldable, .btn_foldable_empty, .btn_sortable, .btn_ellipsis,
|
||||
.btn_more, .btn_annotation, .btn_annotation_go, .btn_annotation_delete, .btn_action, .btn_edit {
|
||||
border: none;
|
||||
color: $o-gray-300;
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 20px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: $o-action !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.btn_sortable > .fa-long-arrow-up, .btn_sortable > .fa-long-arrow-down { color: $o-action }
|
||||
.btn_foldable { color: $o-gray-500 }
|
||||
.btn_foldable_empty:hover { cursor: default }
|
||||
.btn_ellipsis > i { vertical-align: bottom }
|
||||
.btn_more { opacity: 1 }
|
||||
.btn_annotation { margin-left: 6px }
|
||||
.btn_annotation_go { color: $o-gray-600 }
|
||||
.btn_annotation_delete {
|
||||
margin-left: 4px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.btn_action {
|
||||
opacity: 0;
|
||||
background-color: $o-view-background-color;
|
||||
color: $o-gray-600;
|
||||
width: auto;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0 0.25rem;
|
||||
border: 1px solid $o-gray-300;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Dropdown
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.dropdown { display: inline }
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Annotation
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
.annotations {
|
||||
border-top: 1px solid $o-gray-300;
|
||||
font-size: 0.8rem;
|
||||
padding: 24px 0;
|
||||
|
||||
> li {
|
||||
line-height: 24px;
|
||||
margin-left: 24px;
|
||||
&:hover > button { color: $o-action }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
// Dialogs
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
.account_report_annotation_dialog {
|
||||
textarea {
|
||||
border: 1px solid $o-gray-300;
|
||||
border-radius: 0.25rem;
|
||||
height: 120px;
|
||||
padding: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
// Popovers
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
.account_report_popover_edit {
|
||||
padding: .5rem 1rem;
|
||||
box-sizing: content-box;
|
||||
|
||||
.edit_popover_boolean label { padding: 0 12px 0 4px }
|
||||
|
||||
.edit_popover_string {
|
||||
width: 260px;
|
||||
padding: 8px;
|
||||
border-color: $o-gray-200;
|
||||
}
|
||||
|
||||
.btn {
|
||||
color: $o-white;
|
||||
background-color: $o-action;
|
||||
}
|
||||
}
|
||||
|
||||
.account_report_popover_ellipsis {
|
||||
> p {
|
||||
float: left;
|
||||
margin: 1rem;
|
||||
width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.account_report_btn_clone {
|
||||
margin: 1rem 1rem 0 0;
|
||||
border: none;
|
||||
color: $o-gray-300;
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 20px;
|
||||
|
||||
&:hover {
|
||||
color: $o-action !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.account_report_popover_debug {
|
||||
width: 350px;
|
||||
overflow-x: auto;
|
||||
|
||||
> .line_debug {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: .25rem 1rem;
|
||||
|
||||
&:first-child { padding-top: 1rem }
|
||||
&:last-child { padding-bottom: 1rem }
|
||||
|
||||
> span:first-child {
|
||||
color: $o-gray-600;
|
||||
max-width: 25%; //!\\ Not the same as 'width' because of 'display: flex'
|
||||
min-width: 25%; //!\\ Not the same as 'width' because of 'display: flex'
|
||||
white-space: nowrap;
|
||||
margin-right: 10px;
|
||||
}
|
||||
> span:last-child {
|
||||
color: $o-gray-800;
|
||||
max-width: 75%; //!\\ Not the same as 'width' because of 'display: flex'
|
||||
min-width: 75%; //!\\ Not the same as 'width' because of 'display: flex'
|
||||
}
|
||||
|
||||
> code { color: $primary }
|
||||
}
|
||||
|
||||
> .totals_separator { margin: .25rem 1rem }
|
||||
> .engine_separator { margin: 1rem }
|
||||
}
|
||||
|
||||
.carryover_popover {
|
||||
margin: 12px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.o_web_client:has(.annotation_popover) {
|
||||
|
||||
.popover:has(.annotation_tooltip) { visibility: hidden; }
|
||||
|
||||
.popover:has(.annotation_popover) {
|
||||
max-height: 45%;
|
||||
max-width: 60%;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
|
||||
.annotation_popover {
|
||||
overflow: scroll;
|
||||
|
||||
.annotation_popover_line th{
|
||||
background-color: $o-white;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.annotation_popover_line:nth-child(2n+2) { background: $o-gray-200; }
|
||||
|
||||
.annotation_popover_line {
|
||||
.o_datetime_input {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
tr, th, td:not(:has(.btn_annotation_update)):not(:has(.btn_annotation_delete)) {
|
||||
padding: .5rem 1rem .5rem .5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.annotation_popover_editable_cell {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: $o-gray-700;
|
||||
resize: none;
|
||||
width: 85px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label:focus-within input { border: 0; }
|
||||
|
||||
.popover:has(.annotation_tooltip) {
|
||||
|
||||
> .tooltip-inner {
|
||||
padding: 0;
|
||||
color: $o-white;
|
||||
background-color: $o-white;
|
||||
|
||||
> .annotation_tooltip {
|
||||
color: $o-gray-700;
|
||||
background-color: $o-white;
|
||||
white-space: pre-wrap;
|
||||
|
||||
> .annotation_tooltip_line:nth-child(2n+2) { background: $o-gray-200; }
|
||||
|
||||
tr, th, td {
|
||||
padding: .25rem .5rem .25rem .25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
+ .popover-arrow {
|
||||
&.top-0::after { border-right-color: $o-white; }
|
||||
&.bottom-0::after { border-left-color: $o-white; }
|
||||
&.start-0::after { border-bottom-color: $o-white; }
|
||||
&.end-0::after { border-top-color: $o-white; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReport">
|
||||
<t t-call="{{ env.template('AccountReport') }}"/>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportCustomizable">
|
||||
<div class="o_action account_report" t-att-class="cssCustomClass">
|
||||
<ControlPanel>
|
||||
<!-- Buttons bar -->
|
||||
<t t-set-slot="control-panel-create-button">
|
||||
<AccountReportButtonsBar/>
|
||||
</t>
|
||||
<t t-set-slot="control-panel-additional-actions">
|
||||
<AccountReportCogMenu/>
|
||||
</t>
|
||||
|
||||
<!-- Search bar (if configured for report) and filters -->
|
||||
<t t-set-slot="layout-actions">
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<t t-if="controller.options.search_bar">
|
||||
<AccountReportSearchBar initialQuery="initialQuery"/>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-component="env.component('AccountReportFilters')"/>
|
||||
</div>
|
||||
</t>
|
||||
</ControlPanel>
|
||||
|
||||
<div class="o_content">
|
||||
<!-- Sections -->
|
||||
<t t-if="controller.options.sections.length">
|
||||
<div id="section_selector" class="section_selector">
|
||||
<t t-foreach="controller.options.sections" t-as="section" t-key="section_index">
|
||||
<button
|
||||
t-att-class="(controller.options.selected_section_id === section.id) ? 'btn btn-secondary' : 'btn btn-primary'"
|
||||
t-on-click="() => controller.switchToSection(section.id)"
|
||||
t-out="section.name"
|
||||
/>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.options['has_inactive_sections']">
|
||||
<button class="btn btn-secondary" t-on-click="(ev) => controller.reportAction(ev, 'action_display_inactive_sections')" >+</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Warnings -->
|
||||
<t t-if="controller.warnings">
|
||||
<div id="warnings" class="warnings d-print-none">
|
||||
<t t-foreach="controller.warnings" t-as="warningTemplateRef" t-key="warningTemplateRef">
|
||||
<t t-set="warningParams" t-value="controller.warnings[warningTemplateRef]"/>
|
||||
|
||||
<div t-att-class="`alert alert-${warningParams['alert_type'] || 'info'} text-center`">
|
||||
<t t-call="{{ warningTemplateRef }}"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.lines.length">
|
||||
<!-- Table -->
|
||||
<div class="mx-auto fit-content w-print-100">
|
||||
<div class="d-flex align-items-start">
|
||||
<t t-foreach="controller.options['horizontal_split'] ? ['left', 'right'] : [null]" t-as="splitSide" t-key="splitSide">
|
||||
<table
|
||||
class="table table-borderless table-hover w-print-100"
|
||||
t-att-class="tableClasses"
|
||||
>
|
||||
<t t-component="env.component('AccountReportHeader')"/>
|
||||
<tbody>
|
||||
<t t-foreach="controller.lines" t-as="line" t-key="line.id">
|
||||
<t t-if="!splitSide || splitSide === (line.horizontal_split_side || 'left')">
|
||||
<!--
|
||||
We filter lines directly in the template so that the indices passed to AccountReportLine component
|
||||
correspond to indices in the global lines list ; for genericity of the code.
|
||||
-->
|
||||
<t t-if="controller.areLinesOrdered()">
|
||||
<t t-set="orderedIndex" t-value="controller.linesOrder[line_index]"/>
|
||||
<t t-set="orderedLine" t-value="controller.lines[orderedIndex]"/>
|
||||
|
||||
<t t-component="env.component('AccountReportLine')" t-props="{ lineIndex: orderedIndex, line: orderedLine }"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-component="env.component('AccountReportLine')" t-props="{ lineIndex: line_index, line: line }"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- No content -->
|
||||
<div class="o_view_nocontent">
|
||||
<div class="o_nocontent_help">
|
||||
<p class="o_view_nocontent_neutral_face">No data to display !</p>
|
||||
<p>There is no data to display for the given filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,32 @@
|
||||
// Fusion Accounting - Account Report Buttons Bar
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportButtonsBar - Renders the action buttons toolbar for reports.
|
||||
* Displays context-sensitive buttons based on the current report state.
|
||||
*/
|
||||
export class AccountReportButtonsBar extends Component {
|
||||
static template = "fusion_accounting.AccountReportButtonsBar";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.controller = useState(this.env.controller);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Buttons
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get barButtons() {
|
||||
const buttons = [];
|
||||
|
||||
for (const button of this.controller.buttons) {
|
||||
if (button.always_show) {
|
||||
buttons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportButtonsBar">
|
||||
<t t-if="controller.buttons.length">
|
||||
<t t-foreach="barButtons" t-as="barButton" t-key="barButton_index">
|
||||
<button
|
||||
t-att-class="'btn text-nowrap' + (barButton.disabled ? ' disabled' : '') + (barButton_first ? ' btn-primary' : ' btn-secondary')"
|
||||
t-on-click="(ev) => controller.reportAction(ev, barButton.error_action || barButton.action, barButton.action_param, true)"
|
||||
>
|
||||
<t t-out="barButton.name"/>
|
||||
</button>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Fusion Accounting - Account Report Cog Menu
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import {Component, useState} from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
/**
|
||||
* AccountReportCogMenu - Settings dropdown menu for report configuration.
|
||||
* Provides access to report variants, export options, and other settings.
|
||||
*/
|
||||
export class AccountReportCogMenu extends Component {
|
||||
static template = "fusion_accounting.AccountReportCogMenu";
|
||||
static components = {Dropdown, DropdownItem};
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.controller = useState(this.env.controller);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Buttons
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get cogButtons() {
|
||||
const buttons = [];
|
||||
|
||||
for (const button of this.controller.buttons) {
|
||||
if (!button.always_show) {
|
||||
buttons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.AccountReportCogMenu">
|
||||
<t t-if="cogButtons.length">
|
||||
<Dropdown>
|
||||
<button class="btn p-0 ms-1 border-0">
|
||||
<i class="fa fa-fw fa-cog"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="cogButtons" t-as="cogButton" t-key="cogButton_index">
|
||||
<DropdownItem
|
||||
onSelected="(ev) => controller.reportAction(ev, cogButton.error_action || cogButton.action, cogButton.action_param, true)"
|
||||
>
|
||||
<t t-out="cogButton.name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,811 @@
|
||||
// Fusion Accounting - Account Report Controller
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
/* global owl:readonly */
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { removeTaxGroupingFromLineId } from "@fusion_accounting/js/util";
|
||||
|
||||
/**
|
||||
* AccountReportController - Core controller managing report data, options,
|
||||
* and server communication. Handles loading report data, applying filters,
|
||||
* managing line expansion/collapse state, and dispatching report actions.
|
||||
*/
|
||||
export class AccountReportController {
|
||||
constructor(action) {
|
||||
this.action = action;
|
||||
this.actionService = useService("action");
|
||||
this.dialog = useService("dialog");
|
||||
this.orm = useService("orm");
|
||||
}
|
||||
|
||||
async load(env) {
|
||||
this.env = env;
|
||||
this.reportOptionsMap = {};
|
||||
this.reportInformationMap = {};
|
||||
this.lastOpenedSectionByReport = {};
|
||||
this.loadingCallNumberByCacheKey = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(target, name) {
|
||||
return name in target ? target[name] : 0;
|
||||
},
|
||||
set(target, name, newValue) {
|
||||
target[name] = newValue;
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
this.actionReportId = this.action.context.report_id;
|
||||
const isOpeningReport = !this.action?.keep_journal_groups_options // true when opening the report, except when coming from the breadcrumb
|
||||
const mainReportOptions = await this.loadReportOptions(this.actionReportId, false, this.action.params?.ignore_session, isOpeningReport);
|
||||
const cacheKey = this.getCacheKey(mainReportOptions['sections_source_id'], mainReportOptions['report_id']);
|
||||
|
||||
// We need the options to be set and saved in order for the loading to work properly
|
||||
this.options = mainReportOptions;
|
||||
this.reportOptionsMap[cacheKey] = mainReportOptions;
|
||||
this.incrementCallNumber(cacheKey);
|
||||
this.options["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey];
|
||||
this.saveSessionOptions(mainReportOptions);
|
||||
|
||||
const activeSectionPromise = this.displayReport(mainReportOptions['report_id']);
|
||||
this.preLoadClosedSections();
|
||||
await activeSectionPromise;
|
||||
}
|
||||
|
||||
getCacheKey(sectionsSourceId, reportId) {
|
||||
return `${sectionsSourceId}_${reportId}`
|
||||
}
|
||||
|
||||
incrementCallNumber(cacheKey = null) {
|
||||
if (!cacheKey) {
|
||||
cacheKey = this.getCacheKey(this.options['sections_source_id'], this.options['report_id']);
|
||||
}
|
||||
this.loadingCallNumberByCacheKey[cacheKey] += 1;
|
||||
}
|
||||
|
||||
async displayReport(reportId) {
|
||||
const cacheKey = await this.loadReport(reportId);
|
||||
const options = await this.reportOptionsMap[cacheKey];
|
||||
const informationMap = await this.reportInformationMap[cacheKey];
|
||||
if (
|
||||
options !== undefined
|
||||
&& this.loadingCallNumberByCacheKey[cacheKey] === options["loading_call_number"]
|
||||
&& (this.lastOpenedSectionByReport === {} || this.lastOpenedSectionByReport[options['selected_variant_id']] === options['selected_section_id'])
|
||||
) {
|
||||
// the options gotten from the python correspond to the ones that called this displayReport
|
||||
this.options = options;
|
||||
|
||||
// informationMap might be undefined if the promise has been deleted by another call.
|
||||
// Don't need to set data, the call that deleted it is coming to re-put data
|
||||
if (informationMap !== undefined) {
|
||||
this.data = informationMap;
|
||||
// If there is a specific order for lines in the options, we want to use it by default
|
||||
if (this.areLinesOrdered()) {
|
||||
await this.sortLines();
|
||||
}
|
||||
this.setLineVisibility(this.lines);
|
||||
this.refreshVisibleAnnotations();
|
||||
this.saveSessionOptions(this.options);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async reload(optionPath, newOptions) {
|
||||
const rootOptionKey = optionPath ? optionPath.split(".")[0] : "";
|
||||
|
||||
/*
|
||||
When reloading the UI after setting an option filter, invalidate the cached options and data of all sections supporting this filter.
|
||||
This way, those sections will be reloaded (either synchronously when the user tries to access them or asynchronously via the preloading
|
||||
feature), and will then use the new filter value. This ensures the filters are always applied consistently to all sections.
|
||||
*/
|
||||
for (const [cacheKey, cachedOptionsPromise] of Object.entries(this.reportOptionsMap)) {
|
||||
let cachedOptions = await cachedOptionsPromise;
|
||||
|
||||
if (rootOptionKey === "" || cachedOptions.hasOwnProperty(rootOptionKey)) {
|
||||
delete this.reportOptionsMap[cacheKey];
|
||||
delete this.reportInformationMap[cacheKey];
|
||||
}
|
||||
}
|
||||
|
||||
this.saveSessionOptions(newOptions); // The new options will be loaded from the session. Saving them now ensures the new filter is taken into account.
|
||||
await this.displayReport(newOptions['report_id']);
|
||||
}
|
||||
|
||||
async preLoadClosedSections() {
|
||||
let sectionLoaded = false;
|
||||
for (const section of this.options['sections']) {
|
||||
// Preload the first non-loaded section we find amongst this report's sections.
|
||||
const cacheKey = this.getCacheKey(this.options['sections_source_id'], section.id);
|
||||
if (section.id != this.options['report_id'] && !this.reportInformationMap[cacheKey]) {
|
||||
await this.loadReport(section.id, true);
|
||||
|
||||
sectionLoaded = true;
|
||||
// Stop iterating and schedule next call. We don't go on in the loop in case the cache is reset and we need to restart preloading.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let nextCallDelay = (sectionLoaded) ? 100 : 1000;
|
||||
|
||||
const self = this;
|
||||
setTimeout(() => self.preLoadClosedSections(), nextCallDelay);
|
||||
}
|
||||
|
||||
async loadReport(reportId, preloading=false) {
|
||||
const options = await this.loadReportOptions(reportId, preloading, false); // This also sets the promise in the cache
|
||||
const reportToDisplayId = options['report_id']; // Might be different from reportId, in case the report to open uses sections
|
||||
|
||||
const cacheKey = this.getCacheKey(options['sections_source_id'], reportToDisplayId)
|
||||
if (!this.reportInformationMap[cacheKey]) {
|
||||
this.reportInformationMap[cacheKey] = this.orm.call(
|
||||
"account.report",
|
||||
options.readonly_query ? "get_report_information_readonly" : "get_report_information",
|
||||
[
|
||||
reportToDisplayId,
|
||||
options,
|
||||
],
|
||||
{
|
||||
context: this.action.context,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await this.reportInformationMap[cacheKey];
|
||||
|
||||
if (!preloading) {
|
||||
if (options['sections'].length)
|
||||
this.lastOpenedSectionByReport[options['sections_source_id']] = options['selected_section_id'];
|
||||
}
|
||||
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
async loadReportOptions(reportId, preloading=false, ignore_session=false, isOpeningReport=false) {
|
||||
const loadOptions = (ignore_session || !this.hasSessionOptions()) ? (this.action.params?.options || {}) : this.sessionOptions();
|
||||
const cacheKey = this.getCacheKey(loadOptions['sections_source_id'] || reportId, reportId);
|
||||
|
||||
if (!(cacheKey in this.loadingCallNumberByCacheKey)) {
|
||||
this.incrementCallNumber(cacheKey);
|
||||
}
|
||||
loadOptions["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey];
|
||||
|
||||
loadOptions["is_opening_report"] = isOpeningReport;
|
||||
|
||||
if (!this.reportOptionsMap[cacheKey]) {
|
||||
// The options for this section are not loaded nor loading. Let's load them !
|
||||
|
||||
if (preloading)
|
||||
loadOptions['selected_section_id'] = reportId;
|
||||
else {
|
||||
/* Reopen the last opened section by default (cannot be done through regular caching, because composite reports' options are not
|
||||
cached (since they always reroute). */
|
||||
if (this.lastOpenedSectionByReport[reportId])
|
||||
loadOptions['selected_section_id'] = this.lastOpenedSectionByReport[reportId];
|
||||
}
|
||||
|
||||
this.reportOptionsMap[cacheKey] = this.orm.call(
|
||||
"account.report",
|
||||
"get_options",
|
||||
[
|
||||
reportId,
|
||||
loadOptions,
|
||||
],
|
||||
{
|
||||
context: this.action.context,
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the result, and check the report hasn't been rerouted to a section or variant; fix the cache if it has
|
||||
let reportOptions = await this.reportOptionsMap[cacheKey];
|
||||
|
||||
// In case of a reroute, also set the cached options into the reroute target's key
|
||||
const loadedOptionsCacheKey = this.getCacheKey(reportOptions['sections_source_id'], reportOptions['report_id']);
|
||||
if (loadedOptionsCacheKey !== cacheKey) {
|
||||
/* We delete the rerouting report from the cache, to avoid redoing this reroute when reloading the cached options, as it would mean
|
||||
route reports can never be opened directly if they open some variant by default.*/
|
||||
delete this.reportOptionsMap[cacheKey];
|
||||
this.reportOptionsMap[loadedOptionsCacheKey] = reportOptions;
|
||||
|
||||
this.loadingCallNumberByCacheKey[loadedOptionsCacheKey] = 1;
|
||||
delete this.loadingCallNumberByCacheKey[cacheKey];
|
||||
return reportOptions;
|
||||
}
|
||||
}
|
||||
|
||||
return this.reportOptionsMap[cacheKey];
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Generic data getters
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get buttons() {
|
||||
return this.options.buttons;
|
||||
}
|
||||
|
||||
get caretOptions() {
|
||||
return this.data.caret_options;
|
||||
}
|
||||
|
||||
get columnHeadersRenderData() {
|
||||
return this.data.column_headers_render_data;
|
||||
}
|
||||
|
||||
get columnGroupsTotals() {
|
||||
return this.data.column_groups_totals;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.data.context;
|
||||
}
|
||||
|
||||
get filters() {
|
||||
return this.data.filters;
|
||||
}
|
||||
|
||||
get annotations() {
|
||||
return this.data.annotations;
|
||||
}
|
||||
|
||||
get groups() {
|
||||
return this.data.groups;
|
||||
}
|
||||
|
||||
get lines() {
|
||||
return this.data.lines;
|
||||
}
|
||||
|
||||
get warnings() {
|
||||
return this.data.warnings;
|
||||
}
|
||||
|
||||
get linesOrder() {
|
||||
return this.data.lines_order;
|
||||
}
|
||||
|
||||
get report() {
|
||||
return this.data.report;
|
||||
}
|
||||
|
||||
get visibleAnnotations() {
|
||||
return this.data.visible_annotations;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Generic data setters
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
set annotations(value) {
|
||||
this.data.annotations = value;
|
||||
}
|
||||
|
||||
set columnGroupsTotals(value) {
|
||||
this.data.column_groups_totals = value;
|
||||
}
|
||||
|
||||
set lines(value) {
|
||||
this.data.lines = value;
|
||||
this.setLineVisibility(this.lines);
|
||||
}
|
||||
|
||||
set linesOrder(value) {
|
||||
this.data.lines_order = value;
|
||||
}
|
||||
|
||||
set visibleAnnotations(value) {
|
||||
this.data.visible_annotations = value;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get needsColumnPercentComparison() {
|
||||
return this.options.column_percent_comparison === "growth";
|
||||
}
|
||||
|
||||
get hasCustomSubheaders() {
|
||||
return this.columnHeadersRenderData.custom_subheaders.length > 0;
|
||||
}
|
||||
|
||||
get hasDebugColumn() {
|
||||
return Boolean(this.options.show_debug_column);
|
||||
}
|
||||
|
||||
get hasStringDate() {
|
||||
return "date" in this.options && "string" in this.options.date;
|
||||
}
|
||||
|
||||
get hasVisibleAnnotations() {
|
||||
return Boolean(this.visibleAnnotations.length);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Options
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async _updateOption(operationType, optionPath, optionValue=null, reloadUI=false) {
|
||||
const optionKeys = optionPath.split(".");
|
||||
|
||||
let currentOptionKey = null;
|
||||
let option = this.options;
|
||||
|
||||
while (optionKeys.length > 1) {
|
||||
currentOptionKey = optionKeys.shift();
|
||||
option = option[currentOptionKey];
|
||||
|
||||
if (option === undefined)
|
||||
throw new Error(`Invalid option key in _updateOption(): ${ currentOptionKey } (${ optionPath })`);
|
||||
}
|
||||
|
||||
switch (operationType) {
|
||||
case "update":
|
||||
option[optionKeys[0]] = optionValue;
|
||||
break;
|
||||
case "delete":
|
||||
delete option[optionKeys[0]];
|
||||
break;
|
||||
case "toggle":
|
||||
option[optionKeys[0]] = !option[optionKeys[0]];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid operation type in _updateOption(): ${ operationType }`);
|
||||
}
|
||||
|
||||
if (reloadUI) {
|
||||
this.incrementCallNumber();
|
||||
await this.reload(optionPath, this.options);
|
||||
}
|
||||
}
|
||||
|
||||
async updateOption(optionPath, optionValue, reloadUI=false) {
|
||||
await this._updateOption('update', optionPath, optionValue, reloadUI);
|
||||
}
|
||||
|
||||
async deleteOption(optionPath, reloadUI=false) {
|
||||
await this._updateOption('delete', optionPath, null, reloadUI);
|
||||
}
|
||||
|
||||
async toggleOption(optionPath, reloadUI=false) {
|
||||
await this._updateOption('toggle', optionPath, null, reloadUI);
|
||||
}
|
||||
|
||||
async switchToSection(reportId) {
|
||||
this.saveSessionOptions({...this.options, 'selected_section_id': reportId});
|
||||
this.displayReport(reportId);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Session options
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
sessionOptionsID() {
|
||||
/* Options are stored by action report (so, the report that was targetted by the original action triggering this flow).
|
||||
This allows a more intelligent reloading of the previous options during user navigation (especially concerning sections and variants;
|
||||
you expect your report to open by default the same section as last time you opened it in this http session).
|
||||
*/
|
||||
return `account.report:${ this.actionReportId }:${ user.defaultCompany.id }`;
|
||||
}
|
||||
|
||||
hasSessionOptions() {
|
||||
return Boolean(browser.sessionStorage.getItem(this.sessionOptionsID()))
|
||||
}
|
||||
|
||||
saveSessionOptions(options) {
|
||||
browser.sessionStorage.setItem(this.sessionOptionsID(), JSON.stringify(options));
|
||||
}
|
||||
|
||||
sessionOptions() {
|
||||
return JSON.parse(browser.sessionStorage.getItem(this.sessionOptionsID()));
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Lines
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
lineHasDebugData(lineIndex) {
|
||||
return 'debug_popup_data' in this.lines[lineIndex];
|
||||
}
|
||||
|
||||
lineHasGrowthComparisonData(lineIndex) {
|
||||
return Boolean(this.lines[lineIndex].column_percent_comparison_data);
|
||||
}
|
||||
|
||||
isLineAncestorOf(ancestorLineId, lineId) {
|
||||
return lineId.startsWith(`${ancestorLineId}|`);
|
||||
}
|
||||
|
||||
isLineChildOf(childLineId, lineId) {
|
||||
return childLineId.startsWith(`${lineId}|`);
|
||||
}
|
||||
|
||||
isLineRelatedTo(relatedLineId, lineId) {
|
||||
return this.isLineAncestorOf(relatedLineId, lineId) || this.isLineChildOf(relatedLineId, lineId);
|
||||
}
|
||||
|
||||
isNextLineChild(index, lineId) {
|
||||
return index < this.lines.length && this.lines[index].id.startsWith(lineId);
|
||||
}
|
||||
|
||||
isNextLineDirectChild(index, lineId) {
|
||||
return index < this.lines.length && this.lines[index].parent_id === lineId;
|
||||
}
|
||||
|
||||
isTotalLine(lineIndex) {
|
||||
return this.lines[lineIndex].id.includes("|total~~");
|
||||
}
|
||||
|
||||
isLoadMoreLine(lineIndex) {
|
||||
return this.lines[lineIndex].id.includes("|load_more~~");
|
||||
}
|
||||
|
||||
isLoadedLine(lineIndex) {
|
||||
const lineID = this.lines[lineIndex].id;
|
||||
const nextLineIndex = lineIndex + 1;
|
||||
|
||||
return this.isNextLineChild(nextLineIndex, lineID) && !this.isTotalLine(nextLineIndex) && !this.isLoadMoreLine(nextLineIndex);
|
||||
}
|
||||
|
||||
async replaceLineWith(replaceIndex, newLines) {
|
||||
await this.insertLines(replaceIndex, 1, newLines);
|
||||
}
|
||||
|
||||
async insertLinesAfter(insertIndex, newLines) {
|
||||
await this.insertLines(insertIndex + 1, 0, newLines);
|
||||
}
|
||||
|
||||
async insertLines(lineIndex, deleteCount, newLines) {
|
||||
this.lines.splice(lineIndex, deleteCount, ...newLines);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Unfolded/Folded lines
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async unfoldLoadedLine(lineIndex) {
|
||||
const lineId = this.lines[lineIndex].id;
|
||||
let nextLineIndex = lineIndex + 1;
|
||||
|
||||
while (this.isNextLineChild(nextLineIndex, lineId)) {
|
||||
if (this.isNextLineDirectChild(nextLineIndex, lineId)) {
|
||||
const nextLine = this.lines[nextLineIndex];
|
||||
nextLine.visible = true;
|
||||
if (!nextLine.unfoldable && this.isNextLineChild(nextLineIndex + 1, nextLine.id)) {
|
||||
await this.unfoldLine(nextLineIndex);
|
||||
}
|
||||
}
|
||||
nextLineIndex += 1;
|
||||
}
|
||||
return nextLineIndex;
|
||||
}
|
||||
|
||||
async unfoldNewLine(lineIndex) {
|
||||
const options = await this.options;
|
||||
const newLines = await this.orm.call(
|
||||
"account.report",
|
||||
options.readonly_query ? "get_expanded_lines_readonly" : "get_expanded_lines",
|
||||
[
|
||||
this.options['report_id'],
|
||||
this.options,
|
||||
this.lines[lineIndex].id,
|
||||
this.lines[lineIndex].groupby,
|
||||
this.lines[lineIndex].expand_function,
|
||||
this.lines[lineIndex].progress,
|
||||
0,
|
||||
this.lines[lineIndex].horizontal_split_side,
|
||||
],
|
||||
);
|
||||
|
||||
if (this.areLinesOrdered()) {
|
||||
this.updateLinesOrderIndexes(lineIndex, newLines, false)
|
||||
}
|
||||
this.insertLinesAfter(lineIndex, newLines);
|
||||
|
||||
const totalIndex = lineIndex + newLines.length + 1;
|
||||
|
||||
if (this.filters.show_totals && this.lines[totalIndex] && this.isTotalLine(totalIndex))
|
||||
this.lines[totalIndex].visible = true;
|
||||
return totalIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* When unfolding a line of a sorted report, we need to update the linesOrder array by adding the new lines,
|
||||
* which will require subsequent updates on the array.
|
||||
*
|
||||
* - lineOrderValue represents the line index before sorting the report.
|
||||
* @param {Integer} lineIndex: Index of the current line
|
||||
* @param {Array} newLines: Array of lines to be added
|
||||
* @param {Boolean} replaceLine: Useful for the splice of the linesOrder array in case we want to replace some line
|
||||
* example: With the load more, we want to replace the line with others
|
||||
**/
|
||||
updateLinesOrderIndexes(lineIndex, newLines, replaceLine) {
|
||||
let unfoldedLineIndex;
|
||||
// The offset is useful because in case we use 'replaceLineWith' we want to replace the line at index
|
||||
// unfoldedLineIndex with the new lines.
|
||||
const offset = replaceLine ? 0 : 1;
|
||||
for (const [lineOrderIndex, lineOrderValue] of Object.entries(this.linesOrder)) {
|
||||
// Since we will have to add new lines into the linesOrder array, we have to update the index of the lines
|
||||
// having a bigger index than the one we will unfold.
|
||||
// deleteCount of 1 means that a line need to be replaced so the index need to be increase by 1 less than usual
|
||||
if (lineOrderValue > lineIndex) {
|
||||
this.linesOrder[lineOrderIndex] += newLines.length - replaceLine;
|
||||
}
|
||||
// The unfolded line is found, providing a reference for adding children in the 'linesOrder' array.
|
||||
if (lineOrderValue === lineIndex) {
|
||||
unfoldedLineIndex = parseInt(lineOrderIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const arrayOfNewIndex = Array.from({ length: newLines.length }, (dummy, index) => this.linesOrder[unfoldedLineIndex] + index + offset);
|
||||
this.linesOrder.splice(unfoldedLineIndex + offset, replaceLine, ...arrayOfNewIndex);
|
||||
}
|
||||
|
||||
async unfoldLine(lineIndex) {
|
||||
const targetLine = this.lines[lineIndex];
|
||||
let lastLineIndex = lineIndex + 1;
|
||||
|
||||
if (this.isLoadedLine(lineIndex))
|
||||
lastLineIndex = await this.unfoldLoadedLine(lineIndex);
|
||||
else if (targetLine.expand_function) {
|
||||
lastLineIndex = await this.unfoldNewLine(lineIndex);
|
||||
}
|
||||
|
||||
this.setLineVisibility(this.lines.slice(lineIndex + 1, lastLineIndex));
|
||||
targetLine.unfolded = true;
|
||||
this.refreshVisibleAnnotations();
|
||||
|
||||
// Update options
|
||||
if (!this.options.unfolded_lines.includes(targetLine.id))
|
||||
this.options.unfolded_lines.push(targetLine.id);
|
||||
|
||||
this.saveSessionOptions(this.options);
|
||||
}
|
||||
|
||||
foldLine(lineIndex) {
|
||||
const targetLine = this.lines[lineIndex];
|
||||
|
||||
let foldedLinesIDs = new Set([targetLine.id]);
|
||||
let nextLineIndex = lineIndex + 1;
|
||||
|
||||
while (this.isNextLineChild(nextLineIndex, targetLine.id)) {
|
||||
this.lines[nextLineIndex].unfolded = false;
|
||||
this.lines[nextLineIndex].visible = false;
|
||||
|
||||
foldedLinesIDs.add(this.lines[nextLineIndex].id);
|
||||
|
||||
nextLineIndex += 1;
|
||||
}
|
||||
|
||||
targetLine.unfolded = false;
|
||||
|
||||
this.refreshVisibleAnnotations();
|
||||
|
||||
// Update options
|
||||
this.options.unfolded_lines = this.options.unfolded_lines.filter(
|
||||
unfoldedLineID => !foldedLinesIDs.has(unfoldedLineID)
|
||||
);
|
||||
|
||||
this.saveSessionOptions(this.options);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Ordered lines
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
linesCurrentOrderByColumn(columnIndex) {
|
||||
if (this.areLinesOrderedByColumn(columnIndex))
|
||||
return this.options.order_column.direction;
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
areLinesOrdered() {
|
||||
return this.linesOrder != null && this.options.order_column != null;
|
||||
}
|
||||
|
||||
areLinesOrderedByColumn(columnIndex) {
|
||||
return this.areLinesOrdered() && this.options.order_column.expression_label === this.options.columns[columnIndex].expression_label;
|
||||
}
|
||||
|
||||
async sortLinesByColumnAsc(columnIndex) {
|
||||
this.options.order_column = {
|
||||
expression_label: this.options.columns[columnIndex].expression_label,
|
||||
direction: "ASC",
|
||||
};
|
||||
|
||||
await this.sortLines();
|
||||
this.saveSessionOptions(this.options);
|
||||
}
|
||||
|
||||
async sortLinesByColumnDesc(columnIndex) {
|
||||
this.options.order_column = {
|
||||
expression_label: this.options.columns[columnIndex].expression_label,
|
||||
direction: "DESC",
|
||||
};
|
||||
|
||||
await this.sortLines();
|
||||
this.saveSessionOptions(this.options);
|
||||
}
|
||||
|
||||
sortLinesByDefault() {
|
||||
delete this.options.order_column;
|
||||
delete this.data.lines_order;
|
||||
|
||||
this.saveSessionOptions(this.options);
|
||||
}
|
||||
|
||||
async sortLines() {
|
||||
this.linesOrder = await this.orm.call(
|
||||
"account.report",
|
||||
"sort_lines",
|
||||
[
|
||||
this.lines,
|
||||
this.options,
|
||||
true,
|
||||
],
|
||||
{
|
||||
context: this.action.context,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Annotations
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async refreshAnnotations() {
|
||||
this.annotations = await this.orm.call("account.report", "get_annotations", [
|
||||
this.action.context.report_id,
|
||||
this.options,
|
||||
]);
|
||||
|
||||
this.refreshVisibleAnnotations();
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Visibility
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
refreshVisibleAnnotations() {
|
||||
const visibleAnnotations = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(target, name) {
|
||||
return name in target ? target[name] : [];
|
||||
},
|
||||
set(target, name, newValue) {
|
||||
target[name] = newValue;
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.lines.forEach((line) => {
|
||||
line["visible_annotations"] = [];
|
||||
const lineWithoutTaxGrouping = removeTaxGroupingFromLineId(line.id);
|
||||
if (line.visible && this.annotations[lineWithoutTaxGrouping]) {
|
||||
for (const index in this.annotations[lineWithoutTaxGrouping]) {
|
||||
const annotation = this.annotations[lineWithoutTaxGrouping][index];
|
||||
visibleAnnotations[lineWithoutTaxGrouping] = [
|
||||
...visibleAnnotations[lineWithoutTaxGrouping],
|
||||
{ ...annotation },
|
||||
];
|
||||
line["visible_annotations"].push({
|
||||
...annotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
line.visible_annotations &&
|
||||
(!this.annotations[lineWithoutTaxGrouping] || !line.visible)
|
||||
) {
|
||||
delete line.visible_annotations;
|
||||
}
|
||||
});
|
||||
|
||||
this.visibleAnnotations = visibleAnnotations;
|
||||
}
|
||||
|
||||
/**
|
||||
Defines which lines should be visible in the provided list of lines (depending on what is folded).
|
||||
**/
|
||||
setLineVisibility(linesToAssign) {
|
||||
let needHidingChildren = new Set();
|
||||
|
||||
linesToAssign.forEach((line) => {
|
||||
line.visible = !needHidingChildren.has(line.parent_id);
|
||||
|
||||
if (!line.visible || (line.unfoldable &! line.unfolded))
|
||||
needHidingChildren.add(line.id);
|
||||
});
|
||||
|
||||
// If the hide 0 lines is activated we will go through the lines to set the visibility.
|
||||
if (this.options.hide_0_lines) {
|
||||
this.hideZeroLines(linesToAssign);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines whether the line should be visible depending on its value and the ones of its children.
|
||||
* For parent lines, it's visible if there is at least one child with a value different from zero
|
||||
* or if a child is visible, indicating it's a parent line.
|
||||
* For leaf nodes, it's visible if the value is different from zero.
|
||||
*
|
||||
* By traversing the 'lines' array in reverse, we can set the visibility of the lines easily by keeping
|
||||
* a dict of visible lines for each parent.
|
||||
*
|
||||
* @param {Object} lines - The lines for which we want to determine visibility.
|
||||
*/
|
||||
hideZeroLines(lines) {
|
||||
const hasVisibleChildren = new Set();
|
||||
const reversed_lines = [...lines].reverse()
|
||||
|
||||
const number_figure_types = ['integer', 'float', 'monetary', 'percentage'];
|
||||
reversed_lines.forEach((line) => {
|
||||
const isZero = line.columns.every(column => !number_figure_types.includes(column.figure_type) || column.is_zero);
|
||||
|
||||
// If the line has no visible children and all the columns are equals to zero then the line needs to be hidden
|
||||
if (!hasVisibleChildren.has(line.id) && isZero) {
|
||||
line.visible = false;
|
||||
}
|
||||
|
||||
// If the line has a parent_id and is not hidden then we fill the set 'hasVisibleChildren'. Each parent
|
||||
// will have an array of his visible children
|
||||
if (line.parent_id && line.visible) {
|
||||
// This line allows the initialization of that list.
|
||||
hasVisibleChildren.add(line.parent_id);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Server calls
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async reportAction(ev, action, actionParam = null, callOnSectionsSource = false) {
|
||||
// 'ev' might be 'undefined' if event is not triggered from a button/anchor
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
|
||||
let actionOptions = this.options;
|
||||
if (callOnSectionsSource) {
|
||||
// When calling the sections source, we want to keep track of all unfolded lines of all sections
|
||||
const allUnfoldedLines = this.options.sections.length ? [] : [...this.options['unfolded_lines']]
|
||||
|
||||
for (const sectionData of this.options['sections']) {
|
||||
const cacheKey = this.getCacheKey(this.options['sections_source_id'], sectionData['id']);
|
||||
const sectionOptions = await this.reportOptionsMap[cacheKey];
|
||||
if (sectionOptions)
|
||||
allUnfoldedLines.push(...sectionOptions['unfolded_lines']);
|
||||
}
|
||||
|
||||
actionOptions = {...this.options, unfolded_lines: allUnfoldedLines};
|
||||
}
|
||||
|
||||
const dispatchReportAction = await this.orm.call(
|
||||
"account.report",
|
||||
"dispatch_report_action",
|
||||
[
|
||||
this.options['report_id'],
|
||||
actionOptions,
|
||||
action,
|
||||
actionParam,
|
||||
callOnSectionsSource,
|
||||
],
|
||||
);
|
||||
if (dispatchReportAction?.help) {
|
||||
dispatchReportAction.help = owl.markup(dispatchReportAction.help)
|
||||
}
|
||||
|
||||
return dispatchReportAction ? this.actionService.doAction(dispatchReportAction) : null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Budget
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
|
||||
async openBudget(budget) {
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "account.report.budget",
|
||||
res_id: budget.id,
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Fusion Accounting - Account Report Ellipsis
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
import { AccountReportEllipsisPopover } from "@fusion_accounting/components/account_report/ellipsis/popover/ellipsis_popover";
|
||||
|
||||
/**
|
||||
* AccountReportEllipsis - Handles text overflow display with a popover
|
||||
* for viewing and copying the full content of truncated report values.
|
||||
*/
|
||||
export class AccountReportEllipsis extends Component {
|
||||
static template = "fusion_accounting.AccountReportEllipsis";
|
||||
static props = {
|
||||
name: { type: String, optional: true },
|
||||
no_format: { optional: true },
|
||||
type: { type: String, optional: true },
|
||||
maxCharacters: Number,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.popover = useService("popover");
|
||||
this.notification = useService("notification");
|
||||
this.controller = useState(this.env.controller);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Ellipsis
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get triggersEllipsis() {
|
||||
if (this.props.name)
|
||||
return this.props.name.length > this.props.maxCharacters;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
copyEllipsisText() {
|
||||
navigator.clipboard.writeText(this.props.name);
|
||||
this.notification.add(_t("Text copied"), { type: 'success' });
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
|
||||
showEllipsisPopover(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.popoverCloseFn) {
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
ev.currentTarget,
|
||||
AccountReportEllipsisPopover,
|
||||
{
|
||||
name: this.props.name,
|
||||
copyEllipsisText: this.copyEllipsisText.bind(this),
|
||||
},
|
||||
{
|
||||
closeOnClickAway: true,
|
||||
position: localization.direction === "rtl" ? "left" : "right",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportEllipsis">
|
||||
<t t-call="{{ env.template('AccountReportEllipsis') }}"/>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportEllipsisCustomizable">
|
||||
<t t-if="props.type === 'string'">
|
||||
<div class="name">
|
||||
<t t-out="(props.name) ? props.name.substring(0, props.maxCharacters) : ''"/>
|
||||
</div>
|
||||
|
||||
<t t-if="triggersEllipsis">
|
||||
<button
|
||||
class="btn btn_ellipsis"
|
||||
t-on-click="(ev) => this.showEllipsisPopover(ev)"
|
||||
>
|
||||
<i class="fa fa-ellipsis-h"/>
|
||||
</button>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="name">
|
||||
<t t-out="props.name"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,17 @@
|
||||
// Fusion Accounting - Account Report Ellipsis Popover
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportEllipsisPopover - Popover content for displaying the full
|
||||
* text of truncated report cell values with copy-to-clipboard support.
|
||||
*/
|
||||
export class AccountReportEllipsisPopover extends Component {
|
||||
static template = "fusion_accounting.AccountReportEllipsisPopover";
|
||||
static props = {
|
||||
close: Function,
|
||||
name: String,
|
||||
copyEllipsisText: Function,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportEllipsisPopover">
|
||||
<div class="account_report_popover_ellipsis">
|
||||
<p t-out="props.name"/>
|
||||
<button class="btn account_report_btn_clone" t-on-click="() => props.copyEllipsisText()">
|
||||
<i class="fa fa-clone"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterAccountType">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-user me-1"/>Account: <t t-out="selectedAccountType"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="controller.options.account_type" t-as="accountTypeItem" t-key="accountTypeItem_index">
|
||||
<DropdownItem
|
||||
class="{ selected: accountTypeItem.selected }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'account_type.' + accountTypeItem_index + '.selected', reload: true})"
|
||||
closingMode="'none'"
|
||||
t-out="accountTypeItem.name"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportAmlIrFilters">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-star me-1"/>Filters: <t t-out="selectedAmlIrFilters"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="controller.options.aml_ir_filters" t-as="amlIrFilter" t-key="amlIrFilter_index">
|
||||
<DropdownItem
|
||||
class="{ 'selected': amlIrFilter.selected }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'aml_ir_filters.' + amlIrFilter_index + '.selected', reload: true})"
|
||||
closingMode="'none'"
|
||||
t-out="amlIrFilter.name"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterAnalytic">
|
||||
<Dropdown
|
||||
menuClass="'account_report_filter analytic'"
|
||||
>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-filter me-1"/>Analytic
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<div class="dropdown-item gap-2 align-items-center">
|
||||
<label>Accounts</label>
|
||||
<MultiRecordSelector t-props="getMultiRecordSelectorProps('account.analytic.account', 'analytic_accounts')"/>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterAnalyticGroupby">
|
||||
<Dropdown
|
||||
menuClass="'account_report_filter analytic_groupby'"
|
||||
>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="oi oi-group me-1"/>Analytic
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<div class="dropdown-item gap-2 align-items-center">
|
||||
<label>Accounts</label>
|
||||
<MultiRecordSelector t-props="getMultiRecordSelectorProps('account.analytic.account', 'analytic_accounts_groupby')"/>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-item gap-2 align-items-center">
|
||||
<label>Plans</label>
|
||||
<MultiRecordSelector t-props="getMultiRecordSelectorProps('account.analytic.plan', 'analytic_plans_groupby')"/>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterBudget">
|
||||
<Dropdown
|
||||
navigationOptions="dropdownProps"
|
||||
>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-bar-chart me-1"/>Budget
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="controller.options.budgets" t-as="budget" t-key="budget_index">
|
||||
<div class="o-dropdown-item dropdown-item o-navigable d-flex justify-content-between ps-4 pe-1"
|
||||
t-att-class="{ 'selected': budget.selected }"
|
||||
t-on-click="() => this.selectBudget(budget)"
|
||||
>
|
||||
<span t-out="budget.name"/>
|
||||
<button class="btn p-0 px-1" t-on-click.prevent.stop="() => controller.openBudget(budget)">
|
||||
<i class="fa fa-pencil" />
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="dropdown-divider"/>
|
||||
|
||||
<t t-if="controller.options.budgets.filter(x => x.selected).length > 0">
|
||||
<DropdownItem
|
||||
class="{ 'filter_show_all_accounts_hook': true, 'selected': controller.options.show_all_accounts }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'show_all_accounts', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Show All Accounts
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<div class="o-dropdown-item dropdown-item d-flex align-items-center ps-4 pe-1">
|
||||
<input
|
||||
class="o_input"
|
||||
t-att-class="{ 'o_field_invalid': budgetName.invalid }"
|
||||
type="text"
|
||||
placeholder="Budget Name"
|
||||
t-model="budgetName.value"
|
||||
/>
|
||||
<button class="btn btn-link" data-hotkey="s" t-on-click="createBudget">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,163 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportComparisonPeriod">
|
||||
<div t-att-class="(controller.options.comparison.filter !== filter) ? 'invisible' : ''">
|
||||
<input
|
||||
min="1"
|
||||
type="number"
|
||||
t-att-value="controller.options.comparison.number_period"
|
||||
t-on-change="(ev) => this.setNumberPeriods(ev)"
|
||||
/>
|
||||
<label>
|
||||
<t t-out="periodLabel"/>
|
||||
</label>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportFilterComparison">
|
||||
<Dropdown
|
||||
menuClass="'account_report_filter comparison'"
|
||||
navigationOptions="dropdownProps"
|
||||
>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-percent me-1"/>
|
||||
<span>Comparison</span>
|
||||
<t t-if="controller.options.comparison.string">
|
||||
<span>: </span>
|
||||
<t t-if="controller.options.comparison.filter === 'previous_period'">
|
||||
<t t-if="controller.options.comparison.number_period === 1">
|
||||
<span>Previous Period</span>
|
||||
</t>
|
||||
<t t-elif="controller.options.comparison.number_period > 1">
|
||||
<t t-esc="`${ controller.options.comparison.number_period } `"/>
|
||||
<span>Previous Periods</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="controller.options.comparison.filter === 'same_last_year'">
|
||||
<t t-if="controller.options.comparison.number_period === 1">
|
||||
<span>Previous Year</span>
|
||||
</t>
|
||||
<t t-elif="controller.options.comparison.number_period > 1">
|
||||
<t t-esc="`${ controller.options.comparison.number_period } `"/>
|
||||
<span>Previous Years</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-esc="`${ controller.options.comparison.string }`"/>
|
||||
</t>
|
||||
</t>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem
|
||||
class="{ 'selected': (controller.options.comparison.filter === 'no_comparison') }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'comparison.filter', optionValue: 'no_comparison', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
No Comparison
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.comparison.filter === 'previous_period', 'dropdown-item': true, 'period': true }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'comparison.filter', optionValue: 'previous_period', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
<label class="d-flex align-items-center">
|
||||
Previous Period
|
||||
</label>
|
||||
|
||||
<t t-call="fusion_accounting.AccountReportComparisonPeriod">
|
||||
<t t-set="filter" t-value="'previous_period'"/>
|
||||
</t>
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.comparison.filter === 'same_last_year', 'dropdown-item': true, 'period': true }"
|
||||
attrs="{ 'name': 'filter_comparison_same_period_last_year' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'comparison.filter', optionValue: 'same_last_year', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
<label class="d-flex align-items-center">
|
||||
Same Period Last Year
|
||||
</label>
|
||||
|
||||
<t t-call="fusion_accounting.AccountReportComparisonPeriod">
|
||||
<t t-set="filter" t-value="'same_last_year'"/>
|
||||
</t>
|
||||
</DropdownItem>
|
||||
|
||||
<t t-if="controller.options.date.mode === 'range'">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.comparison.filter === 'custom', 'dropdown-item': true, 'date': true }"
|
||||
attrs="{ 'name': 'filter_comparison_custom' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'comparison.filter', optionValue: 'custom'})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
<label class="d-flex align-items-center">
|
||||
Custom Dates
|
||||
</label>
|
||||
|
||||
<div class="d-flex flex-row"
|
||||
t-att-class="(controller.options.comparison.filter !== 'custom') ? 'invisible' : ''">
|
||||
<DateTimeInput
|
||||
type="'date'"
|
||||
value="dateFrom('comparison')"
|
||||
onChange="(dateFrom) => this.setDateFrom('comparison', dateFrom)"
|
||||
/>
|
||||
<label class="d-flex align-items-center">
|
||||
to
|
||||
</label>
|
||||
<DateTimeInput
|
||||
type="'date'"
|
||||
value="dateTo('comparison')"
|
||||
onChange="(dateTo) => this.setDateTo('comparison', dateTo)"
|
||||
/>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.options.date.mode === 'single'">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.comparison.filter === 'custom', 'dropdown-item': true, 'date': true }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'comparison.filter', optionValue: 'custom'})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
<label class="d-flex align-items-center">
|
||||
Specific Date
|
||||
</label>
|
||||
|
||||
<div t-att-class="(controller.options.comparison.filter !== 'custom') ? 'invisible' : ''">
|
||||
<DateTimeInput
|
||||
type="'date'"
|
||||
value="dateTo('comparison')"
|
||||
onChange="(dateTo) => this.setDateTo('comparison', dateTo)"
|
||||
/>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<div class="dropdown-divider"/>
|
||||
|
||||
<div class="dropdown-item period order">
|
||||
<label class="d-flex align-items-center pe-none">
|
||||
Period order
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<select name="select_order"
|
||||
class="o_input pe-3"
|
||||
t-on-change="(ev) => this.filterClicked({ optionKey: 'comparison.period_order', optionValue: ev.target.value, reload: true})">
|
||||
<option t-foreach="availablePeriodOrder"
|
||||
t-as="order"
|
||||
t-key="order_index"
|
||||
t-att-value="order"
|
||||
t-att-selected="availablePeriodOrder[order] === periodOrder"
|
||||
t-out="availablePeriodOrder[order]"
|
||||
/>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterDateSelection">
|
||||
<t t-foreach="this.dateFilters(controller.options.date.mode)" t-as="dateFilter" t-key="dateFilter_index">
|
||||
<DropdownItem
|
||||
class="{ 'd-flex justify-content-between date_filter': true, 'selected': (this.isPeriodSelected(dateFilter.period)) }"
|
||||
onSelected="() => this.selectDateFilter(dateFilter.period, dateFilter.period != controller.options.date.period_type)"
|
||||
closingMode="'none'"
|
||||
>
|
||||
<div class="filter_name pe-3">
|
||||
<t t-out="dateFilter.name"/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button
|
||||
class="btn_previous_date fa fa-caret-left"
|
||||
t-on-click="() => this.selectPreviousPeriod(dateFilter.period)"
|
||||
/>
|
||||
<time class="d-flex justify-content-center time_text">
|
||||
<t t-out="this.displayPeriod(dateFilter.period)"/>
|
||||
</time>
|
||||
<button
|
||||
class="btn_next_date fa fa-caret-right"
|
||||
t-on-click="() => this.selectNextPeriod(dateFilter.period)"
|
||||
/>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportFilterDate">
|
||||
<Dropdown
|
||||
menuClass="'account_report_filter date'"
|
||||
>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-calendar me-1"/>
|
||||
<t t-out="controller.options.date.string"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-if="controller.options.date.mode === 'single'">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.date.filter === 'today' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'date.filter', optionValue: 'today', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Today
|
||||
</DropdownItem>
|
||||
|
||||
<t t-call="fusion_accounting.AccountReportFilterDateSelection"/>
|
||||
|
||||
<div id="filter_date_divider_single" class="dropdown-divider"/>
|
||||
|
||||
<div
|
||||
class="dropdown-item date d-flex justify-content-between"
|
||||
t-att-class="{ 'selected': controller.options.date.filter === 'custom' }"
|
||||
t-on-click="() => this.filterClicked({ optionKey: 'date.filter', optionValue: 'custom'})"
|
||||
>
|
||||
<label class="d-flex align-items-center filter_name">
|
||||
Specific Date
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="d-flex justify-content-center flex-grow-1"
|
||||
t-att-class="{ 'invisible': controller.options.date.filter !== 'custom' }"
|
||||
>
|
||||
<DateTimeInput
|
||||
type="'date'"
|
||||
value="dateTo('date')"
|
||||
onChange="(dateTo) => this.setDateTo('date', dateTo)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.options.date.mode === 'range'">
|
||||
<t t-call="fusion_accounting.AccountReportFilterDateSelection"/>
|
||||
|
||||
<div id="filter_date_divider_range" class="dropdown-divider"/>
|
||||
|
||||
<div
|
||||
class="dropdown-item date d-flex flex-row justify-space-between"
|
||||
t-att-class="{ 'selected': controller.options.date.filter === 'custom' }"
|
||||
t-on-click="() => this.filterClicked({ optionKey: 'date.filter', optionValue: 'custom'})"
|
||||
>
|
||||
<label class="d-flex align-items-center">
|
||||
Custom Dates
|
||||
</label>
|
||||
<div class="d-flex flex-row"
|
||||
t-att-class="{ 'invisible': controller.options.date.filter !== 'custom' }">
|
||||
<DateTimeInput
|
||||
type="'date'"
|
||||
value="dateFrom('date')"
|
||||
onChange="(dateFrom) => this.setDateFrom('date', dateFrom)"
|
||||
/>
|
||||
<label class="d-flex align-items-center">
|
||||
to
|
||||
</label>
|
||||
<DateTimeInput
|
||||
type="'date'"
|
||||
value="dateTo('date')"
|
||||
onChange="(dateTo) => this.setDateTo('date', dateTo)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterExtraOptions">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-sliders me-1"/>
|
||||
<t t-if="selectedExtraOptions">
|
||||
<t t-out="selectedExtraOptions"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Options
|
||||
</t>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-if="controller.groups.account_readonly and controller.filters.show_draft">
|
||||
<DropdownItem
|
||||
class="{ 'filter_show_draft_hook': true, 'selected': controller.options.all_entries }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'all_entries', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Draft Entries
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.groups.account_readonly and controller.filters.show_analytic_groupby">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.include_analytic_without_aml }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'include_analytic_without_aml', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Analytic Simulations
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.filters.show_hierarchy">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.hierarchy }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'hierarchy', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Hierarchy and Subtotals
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.filters.show_unreconciled">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.unreconciled }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'unreconciled', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Unreconciled Entries
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.filters.show_all">
|
||||
<DropdownItem
|
||||
class="{ 'filter_show_all_hook': true, 'selected': controller.options.unfold_all }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'unfold_all', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Unfold All
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.options.integer_rounding">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.integer_rounding_enabled }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'integer_rounding_enabled'})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Integer Rounding
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<div class="dropdown-divider" t-if="hasUIFilter"/>
|
||||
|
||||
<t t-if="controller.filters.show_hide_0_lines !== 'never'">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.hide_0_lines }"
|
||||
onSelected="() => this.toggleHideZeroLines()"
|
||||
>
|
||||
Hide lines at 0
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="'horizontal_split' in controller.options">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.horizontal_split }"
|
||||
onSelected="() => this.toggleHorizontalSplit()"
|
||||
>
|
||||
Split Horizontally
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterFiscalPosition">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-book me-1"/>Fiscal Position: <t t-esc="selectedFiscalPositionName"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-if="controller.groups.account_readonly and controller.options.allow_domestic">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.fiscal_position === 'domestic' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'fiscal_position', optionValue: 'domestic', reload: true})"
|
||||
>
|
||||
Domestic
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.groups.account_readonly">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.fiscal_position === 'all' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'fiscal_position', optionValue: 'all', reload: true})"
|
||||
>
|
||||
All
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-foreach="controller.options.available_vat_fiscal_positions" t-as="fiscalPosition" t-key="fiscalPosition_index">
|
||||
<t t-if="fiscalPosition_first">
|
||||
<div class="dropdown-divider"/>
|
||||
</t>
|
||||
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.fiscal_position === fiscalPosition.id }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'fiscal_position', optionValue: fiscalPosition.id, reload: true})"
|
||||
>
|
||||
<t t-esc="fiscalPosition.name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterHorizontalGroups">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-filter me-1"/>Horizontal Group: <t t-out="selectedHorizontalGroupName"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.selected_horizontal_group_id === null }"
|
||||
onSelected="() => this.selectHorizontalGroup(null)"
|
||||
closingMode="'none'"
|
||||
>
|
||||
None
|
||||
</DropdownItem>
|
||||
|
||||
<t t-foreach="controller.options.available_horizontal_groups" t-as="horizontalGroup" t-key="horizontalGroup_index">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.selected_horizontal_group_id === horizontalGroup.id }"
|
||||
onSelected="() => this.selectHorizontalGroup(horizontalGroup.id)"
|
||||
closingMode="'none'"
|
||||
>
|
||||
<t t-out="horizontalGroup.name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterJournal">
|
||||
<Dropdown disabled="!controller.options.journals.length">
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-book me-1"/><t t-out="controller.options.name_journal_group"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="controller.options.journals" t-as="journal" t-key="journal_index">
|
||||
<t t-if="journal.id === 'divider'">
|
||||
<div t-att-class="'d-flex dropdown-header w-100 align-items-center' + (journal.model == 'res.company' ? ' pt-0 ps-0 mx-0 my-1' : '')">
|
||||
<t t-if="journal.model == 'account.journal.group'">
|
||||
<t t-out="journal.name"/>
|
||||
</t>
|
||||
<t t-if="journal.model == 'res.company'">
|
||||
<button class="btn btn_foldable d-flex align-items-center w-100 border-0"
|
||||
t-on-click="() => this.unfoldCompanyJournals(journal)"
|
||||
>
|
||||
<i t-att-class="journal.unfolded ? 'fa fa-caret-down' : 'fa fa-caret-right'"/>
|
||||
<span class="pt-0 mt-0 ps-2 dropdown-header" t-out="journal.name"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
<t t-elif="['account.journal', 'account.journal.group'].includes(journal.model)">
|
||||
<DropdownItem
|
||||
class="{ 'selected': journal.selected, 'ps-4': true, 'd-none': (journal.model == 'account.journal' and !journal.visible) }"
|
||||
onSelected="() => this.selectJournal(journal)"
|
||||
closingMode="'none'"
|
||||
t-out="journal.name"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterPartner">
|
||||
<Dropdown
|
||||
menuClass="'account_report_filter partner'"
|
||||
>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-folder-open me-1"/>Partners
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<div class="dropdown-item gap-2 align-items-center">
|
||||
<label>Partners</label>
|
||||
<MultiRecordSelector t-props="getMultiRecordSelectorProps('res.partner', 'partner_ids')" domain="[['parent_id', '=', false]]"/>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-item gap-2 align-items-center">
|
||||
<label>Tags</label>
|
||||
<MultiRecordSelector t-props="getMultiRecordSelectorProps('res.partner.category', 'partner_categories')"/>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterRoundingUnit">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<t t-out="roundingUnitName(this.controller.options['rounding_unit'])"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="controller.options.rounding_unit_names" t-as="roundingUnitValue" t-key="roundingUnitValue_index">
|
||||
<DropdownItem
|
||||
class="{ 'selected': (controller.options.rounding_unit == roundingUnitValue) }"
|
||||
onSelected="() => this.filterRoundingUnit(roundingUnitValue)"
|
||||
>
|
||||
<t t-out="roundingUnitName(roundingUnitValue)"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterTaxUnit">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-home me-1"/>Tax Unit: <t t-esc="selectedTaxUnitName"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-if="controller.groups.account_readonly">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.tax_unit === 'company_only' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'tax_unit', optionValue: 'company_only', reload: true})"
|
||||
>
|
||||
Company Only
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-foreach="controller.options.available_tax_units" t-as="taxUnit" t-key="taxUnit_index">
|
||||
<t t-if="taxUnit_first">
|
||||
<div class="dropdown-divider"/>
|
||||
</t>
|
||||
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.tax_unit === taxUnit.id }"
|
||||
onSelected="() => this.filterTaxUnit(taxUnit)"
|
||||
t-out="taxUnit.name"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilterVariant">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-book me-1"/>Report: <t t-esc="selectedVariantName"/>
|
||||
</button>
|
||||
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="controller.options.available_variants" t-as="variant" t-key="variant_index">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.selected_variant_id === variant.id }"
|
||||
onSelected="() => this.filterVariant(variant.id)"
|
||||
t-out="variant.name"
|
||||
/>
|
||||
</t>
|
||||
<t t-if="controller.options.has_inactive_variants">
|
||||
<div role="separator" class="dropdown-divider"/>
|
||||
<DropdownItem onSelected="(ev) => controller.reportAction(ev, 'action_view_all_variants', {})">
|
||||
Enable more ...
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,652 @@
|
||||
// Fusion Accounting - Account Report Filters
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { WarningDialog } from "@web/core/errors/error_dialogs";
|
||||
|
||||
import { DateTimeInput } from '@web/core/datetime/datetime_input';
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { MultiRecordSelector } from "@web/core/record_selectors/multi_record_selector";
|
||||
import { formatDate} from "@web/core/l10n/dates";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
/**
|
||||
* AccountReportFilters - Filter panel component for financial reports.
|
||||
* Provides date range selection, comparison periods, journal filtering,
|
||||
* partner filtering, analytic account grouping, and various report options.
|
||||
*/
|
||||
export class AccountReportFilters extends Component {
|
||||
static template = "fusion_accounting.AccountReportFilters";
|
||||
static props = {};
|
||||
static components = {
|
||||
DateTimeInput,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
MultiRecordSelector,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.dialog = useService("dialog");
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
this.companyService = useService("company");
|
||||
this.controller = useState(this.env.controller);
|
||||
if (this.env.controller.options.date) {
|
||||
this.dateFilter = useState(this.initDateFilters());
|
||||
}
|
||||
this.budgetName = useState({
|
||||
value: "",
|
||||
invalid: false,
|
||||
});
|
||||
this.timeout = null;
|
||||
}
|
||||
|
||||
focusInnerInput(index, items) {
|
||||
const selectedItem = items[index];
|
||||
selectedItem.el.querySelector(":scope input")?.focus();
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Getters
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get selectedFiscalPositionName() {
|
||||
switch (this.controller.options.fiscal_position) {
|
||||
case "domestic":
|
||||
return _t("Domestic");
|
||||
case "all":
|
||||
return _t("All");
|
||||
default:
|
||||
for (const fiscalPosition of this.controller.options.available_vat_fiscal_positions) {
|
||||
if (fiscalPosition.id === this.controller.options.fiscal_position) {
|
||||
return fiscalPosition.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return _t("None");
|
||||
}
|
||||
|
||||
get selectedHorizontalGroupName() {
|
||||
for (const horizontalGroup of this.controller.options.available_horizontal_groups) {
|
||||
if (horizontalGroup.id === this.controller.options.selected_horizontal_group_id) {
|
||||
return horizontalGroup.name;
|
||||
}
|
||||
}
|
||||
return _t("None");
|
||||
}
|
||||
|
||||
get isHorizontalGroupSelected() {
|
||||
return this.controller.options.available_horizontal_groups.some((group) => {
|
||||
return group.id === this.controller.options.selected_horizontal_group_id;
|
||||
});
|
||||
}
|
||||
|
||||
get selectedTaxUnitName() {
|
||||
for (const taxUnit of this.controller.options.available_tax_units) {
|
||||
if (taxUnit.id === this.controller.options.tax_unit) {
|
||||
return taxUnit.name;
|
||||
}
|
||||
}
|
||||
return _t("Company Only");
|
||||
}
|
||||
|
||||
get selectedVariantName() {
|
||||
for (const variant of this.controller.options.available_variants) {
|
||||
if (variant.id === this.controller.options.selected_variant_id) {
|
||||
return variant.name;
|
||||
}
|
||||
}
|
||||
return _t("None");
|
||||
}
|
||||
|
||||
get selectedSectionName() {
|
||||
for (const section of this.controller.options.sections)
|
||||
if (section.id === this.controller.options.selected_section_id)
|
||||
return section.name;
|
||||
}
|
||||
|
||||
get selectedAccountType() {
|
||||
let selectedAccountType = this.controller.options.account_type.filter(
|
||||
(accountType) => accountType.selected,
|
||||
);
|
||||
if (
|
||||
!selectedAccountType.length ||
|
||||
selectedAccountType.length === this.controller.options.account_type.length
|
||||
) {
|
||||
return _t("All");
|
||||
}
|
||||
|
||||
const accountTypeMappings = [
|
||||
{ list: ["trade_receivable", "non_trade_receivable"], name: _t("All Receivable") },
|
||||
{ list: ["trade_payable", "non_trade_payable"], name: _t("All Payable") },
|
||||
{ list: ["trade_receivable", "trade_payable"], name: _t("Trade Partners") },
|
||||
{ list: ["non_trade_receivable", "non_trade_payable"], name: _t("Non Trade Partners") },
|
||||
];
|
||||
|
||||
const listToDisplay = [];
|
||||
for (const mapping of accountTypeMappings) {
|
||||
if (
|
||||
mapping.list.every((accountType) =>
|
||||
selectedAccountType.map((accountType) => accountType.id).includes(accountType),
|
||||
)
|
||||
) {
|
||||
listToDisplay.push(mapping.name);
|
||||
// Delete already checked id
|
||||
selectedAccountType = selectedAccountType.filter(
|
||||
(accountType) => !mapping.list.includes(accountType.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return listToDisplay
|
||||
.concat(selectedAccountType.map((accountType) => accountType.name))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
get selectedAmlIrFilters() {
|
||||
const selectedFilters = this.controller.options.aml_ir_filters.filter(
|
||||
(irFilter) => irFilter.selected,
|
||||
);
|
||||
|
||||
if (selectedFilters.length === 1) {
|
||||
return selectedFilters[0].name;
|
||||
} else if (selectedFilters.length > 1) {
|
||||
return _t("%s selected", selectedFilters.length);
|
||||
} else {
|
||||
return _t("None");
|
||||
}
|
||||
}
|
||||
|
||||
get availablePeriodOrder() {
|
||||
return { descending: _t("Descending"), ascending: _t("Ascending") };
|
||||
}
|
||||
|
||||
get periodOrder() {
|
||||
return this.controller.options.comparison.period_order === "descending"
|
||||
? _t("Descending")
|
||||
: _t("Ascending");
|
||||
}
|
||||
|
||||
get selectedExtraOptions() {
|
||||
const selectedExtraOptions = [];
|
||||
|
||||
if (this.controller.groups.account_readonly && this.controller.filters.show_draft) {
|
||||
selectedExtraOptions.push(
|
||||
this.controller.options.all_entries
|
||||
? _t("With Draft Entries")
|
||||
: _t("Posted Entries"),
|
||||
);
|
||||
}
|
||||
if (this.controller.filters.show_unreconciled && this.controller.options.unreconciled) {
|
||||
selectedExtraOptions.push(_t("Unreconciled Entries"));
|
||||
}
|
||||
if (this.controller.options.include_analytic_without_aml) {
|
||||
selectedExtraOptions.push(_t("Including Analytic Simulations"));
|
||||
}
|
||||
return selectedExtraOptions.join(", ");
|
||||
}
|
||||
|
||||
get dropdownProps() {
|
||||
return {
|
||||
shouldFocusChildInput: false,
|
||||
hotkeys: {
|
||||
arrowright: (index, items) => this.focusInnerInput(index, items),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get periodLabel() {
|
||||
return this.controller.options.comparison.number_period > 1 ? _t("Periods") : _t("Period");
|
||||
}
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get hasAnalyticGroupbyFilter() {
|
||||
return Boolean(this.controller.groups.analytic_accounting) && (Boolean(this.controller.filters.show_analytic_groupby) || Boolean(this.controller.filters.show_analytic_plan_groupby));
|
||||
}
|
||||
|
||||
get hasCodesFilter() {
|
||||
return Boolean(this.controller.options.sales_report_taxes?.operation_category?.goods);
|
||||
}
|
||||
|
||||
get hasExtraOptionsFilter() {
|
||||
return (
|
||||
"report_cash_basis" in this.controller.options ||
|
||||
this.controller.filters.show_draft ||
|
||||
this.controller.filters.show_all ||
|
||||
this.controller.filters.show_unreconciled ||
|
||||
this.hasUIFilter
|
||||
);
|
||||
}
|
||||
|
||||
get hasUIFilter() {
|
||||
return (
|
||||
this.controller.filters.show_hide_0_lines !== "never" ||
|
||||
"horizontal_split" in this.controller.options
|
||||
);
|
||||
}
|
||||
|
||||
get hasFiscalPositionFilter() {
|
||||
const isMultiCompany = this.controller.options.companies.length > 1;
|
||||
const minimumFiscalPosition = this.controller.options.allow_domestic ? 0 : 1;
|
||||
const hasFiscalPositions =
|
||||
this.controller.options.available_vat_fiscal_positions.length > minimumFiscalPosition;
|
||||
return hasFiscalPositions && isMultiCompany;
|
||||
}
|
||||
|
||||
get isBudgetSelected() {
|
||||
return this.controller.options.budgets?.some((budget) => {
|
||||
return budget.selected;
|
||||
});
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Dates
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Getters
|
||||
dateFrom(optionKey) {
|
||||
return DateTime.fromISO(this.controller.options[optionKey].date_from);
|
||||
}
|
||||
|
||||
dateTo(optionKey) {
|
||||
return DateTime.fromISO(this.controller.options[optionKey].date_to);
|
||||
}
|
||||
|
||||
// Setters
|
||||
setDate(optionKey, type, date) {
|
||||
if (date) {
|
||||
this.controller.options[optionKey][`date_${type}`] = date;
|
||||
this.applyFilters(optionKey);
|
||||
}
|
||||
else {
|
||||
this.dialog.add(WarningDialog, {
|
||||
title: _t("Odoo Warning"),
|
||||
message: _t("Date cannot be empty"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDateFrom(optionKey, dateFrom) {
|
||||
this.setDate(optionKey, 'from', dateFrom);
|
||||
}
|
||||
|
||||
setDateTo(optionKey, dateTo) {
|
||||
this.setDate(optionKey, 'to', dateTo);
|
||||
}
|
||||
|
||||
dateFilters(mode) {
|
||||
switch (mode) {
|
||||
case "single":
|
||||
return [
|
||||
{"name": _t("End of Month"), "period": "month"},
|
||||
{"name": _t("End of Quarter"), "period": "quarter"},
|
||||
{"name": _t("End of Year"), "period": "year"},
|
||||
];
|
||||
case "range":
|
||||
return [
|
||||
{"name": _t("Month"), "period": "month"},
|
||||
{"name": _t("Quarter"), "period": "quarter"},
|
||||
{"name": _t("Year"), "period": "year"},
|
||||
];
|
||||
default:
|
||||
throw new Error(`Invalid mode in dateFilters(): ${ mode }`);
|
||||
}
|
||||
}
|
||||
|
||||
initDateFilters() {
|
||||
const filters = {
|
||||
"month": 0,
|
||||
"quarter": 0,
|
||||
"year": 0,
|
||||
"tax_period": 0
|
||||
};
|
||||
|
||||
const specifier = this.controller.options.date.filter.split('_')[0];
|
||||
const periodType = this.controller.options.date.period_type;
|
||||
// In case the period is fiscalyear it will be computed exactly like a year period.
|
||||
const period = periodType === "fiscalyear" ? "year" : periodType;
|
||||
// Set the filter value based on the specifier
|
||||
filters[period] = this.controller.options.date.period || (specifier === 'previous' ? -1 : specifier === 'next' ? 1 : 0);
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
getDateFilter(periodType) {
|
||||
if (this.dateFilter[periodType] > 0) {
|
||||
return `next_${periodType}`;
|
||||
} else if (this.dateFilter[periodType] === 0) {
|
||||
return `this_${periodType}`;
|
||||
} else {
|
||||
return `previous_${periodType}`;
|
||||
}
|
||||
}
|
||||
|
||||
selectDateFilter(periodType, reload = false) {
|
||||
this.filterClicked({ optionKey: "date.filter", optionValue: this.getDateFilter(periodType)});
|
||||
this.filterClicked({ optionKey: "date.period", optionValue: this.dateFilter[periodType], reload: reload});
|
||||
}
|
||||
|
||||
selectPreviousPeriod(periodType) {
|
||||
this._changePeriod(periodType, -1);
|
||||
}
|
||||
|
||||
selectNextPeriod(periodType) {
|
||||
this._changePeriod(periodType, 1);
|
||||
}
|
||||
|
||||
_changePeriod(periodType, increment) {
|
||||
this.dateFilter[periodType] = this.dateFilter[periodType] + increment;
|
||||
|
||||
this.controller.updateOption("date.filter", this.getDateFilter(periodType));
|
||||
this.controller.updateOption("date.period", this.dateFilter[periodType]);
|
||||
|
||||
this.applyFilters("date.period");
|
||||
}
|
||||
|
||||
isPeriodSelected(periodType) {
|
||||
return this.controller.options.date.filter.includes(periodType)
|
||||
}
|
||||
|
||||
displayPeriod(periodType) {
|
||||
const dateTo = DateTime.now();
|
||||
|
||||
switch (periodType) {
|
||||
case "month":
|
||||
return this._displayMonth(dateTo);
|
||||
case "quarter":
|
||||
return this._displayQuarter(dateTo);
|
||||
case "year":
|
||||
return this._displayYear(dateTo);
|
||||
case "tax_period":
|
||||
return this._displayTaxPeriod(dateTo);
|
||||
default:
|
||||
throw new Error(`Invalid period type in displayPeriod(): ${ periodType }`);
|
||||
}
|
||||
}
|
||||
|
||||
_displayMonth(dateTo) {
|
||||
return dateTo.plus({ months: this.dateFilter.month }).toFormat("MMMM yyyy");
|
||||
}
|
||||
|
||||
_displayQuarter(dateTo) {
|
||||
const quarterMonths = {
|
||||
1: { 'start': 1, 'end': 3 },
|
||||
2: { 'start': 4, 'end': 6 },
|
||||
3: { 'start': 7, 'end': 9 },
|
||||
4: { 'start': 10, 'end': 12 },
|
||||
}
|
||||
|
||||
dateTo = dateTo.plus({ months: this.dateFilter.quarter * 3 });
|
||||
|
||||
const quarterDateFrom = DateTime.utc(dateTo.year, quarterMonths[dateTo.quarter]['start'], 1)
|
||||
const quarterDateTo = DateTime.utc(dateTo.year, quarterMonths[dateTo.quarter]['end'], 1)
|
||||
|
||||
return `${ formatDate(quarterDateFrom, {format: "MMM"}) } - ${ formatDate(quarterDateTo, {format: "MMM yyyy"}) }`;
|
||||
}
|
||||
|
||||
_displayYear(dateTo) {
|
||||
return dateTo.plus({ years: this.dateFilter.year }).toFormat("yyyy");
|
||||
}
|
||||
|
||||
_displayTaxPeriod(dateTo) {
|
||||
const periodicitySettings = this.controller.options.tax_periodicity;
|
||||
const targetDateInPeriod = dateTo.plus({months: periodicitySettings.months_per_period * this.dateFilter['tax_period']})
|
||||
const [start, end] = this._computeTaxPeriodDates(periodicitySettings, targetDateInPeriod);
|
||||
|
||||
if (periodicitySettings.start_month == 1 && periodicitySettings.start_day == 1) {
|
||||
switch (periodicitySettings.months_per_period) {
|
||||
case 1: return end.toFormat("MMMM yyyy");
|
||||
case 3: return `Q${end.quarter} ${dateTo.year}`;
|
||||
case 12: return end.toFormat("yyyy");
|
||||
}
|
||||
}
|
||||
|
||||
return formatDate(start) + ' - ' + formatDate(end);
|
||||
}
|
||||
|
||||
_computeTaxPeriodDates(periodicitySettings, dateInsideTargettesPeriod) {
|
||||
/**
|
||||
* This function needs to stay consistent with the one inside res_company from the fusion_accounting module.
|
||||
* function_name = _get_tax_closing_period_boundaries
|
||||
*/
|
||||
const startMonth = periodicitySettings.start_month;
|
||||
const startDay = periodicitySettings.start_day
|
||||
const monthsPerPeriod = periodicitySettings.months_per_period;
|
||||
const aligned_date = dateInsideTargettesPeriod.minus({days: startDay - 1})
|
||||
let year = aligned_date.year;
|
||||
const monthOffset = aligned_date.month - startMonth;
|
||||
|
||||
let periodNumber = Math.floor(monthOffset / monthsPerPeriod) + 1;
|
||||
|
||||
if (dateInsideTargettesPeriod < DateTime.now().set({year: year, month: startMonth, day: startDay})) {
|
||||
year -= 1;
|
||||
periodNumber = Math.floor((12 + monthOffset) / monthsPerPeriod) + 1;
|
||||
}
|
||||
|
||||
let deltaMonth = periodNumber * monthsPerPeriod;
|
||||
|
||||
const endDate = DateTime.utc(year, startMonth, 1).plus({ months: deltaMonth, days: startDay-2})
|
||||
const startDate = DateTime.utc(year, startMonth, 1).plus({ months: deltaMonth-monthsPerPeriod }).set({ day: startDay})
|
||||
return [startDate, endDate];
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Number of periods
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
setNumberPeriods(ev) {
|
||||
const numberPeriods = ev.target.value;
|
||||
|
||||
if (numberPeriods >= 1)
|
||||
this.controller.options.comparison.number_period = parseInt(numberPeriods);
|
||||
else
|
||||
this.dialog.add(WarningDialog, {
|
||||
title: _t("Odoo Warning"),
|
||||
message: _t("Number of periods cannot be smaller than 1"),
|
||||
});
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Records
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
getMultiRecordSelectorProps(resModel, optionKey) {
|
||||
return {
|
||||
resModel,
|
||||
resIds: this.controller.options[optionKey],
|
||||
update: (resIds) => {
|
||||
this.filterClicked({ optionKey: optionKey, optionValue: resIds, reload: true});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Rounding unit
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
roundingUnitName(roundingUnit) {
|
||||
return _t("In %s", this.controller.options["rounding_unit_names"][roundingUnit][0]);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Generic filters
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async filterClicked({ optionKey, optionValue = undefined, reload = false}) {
|
||||
if (optionValue !== undefined) {
|
||||
await this.controller.updateOption(optionKey, optionValue);
|
||||
} else {
|
||||
await this.controller.toggleOption(optionKey);
|
||||
}
|
||||
|
||||
if (reload) {
|
||||
await this.applyFilters(optionKey);
|
||||
}
|
||||
}
|
||||
|
||||
async applyFilters(optionKey = null, delay = 500) {
|
||||
// We only call the reload after the delay is finished, to avoid doing 5 calls if you want to click on 5 journals
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
this.controller.incrementCallNumber();
|
||||
|
||||
this.timeout = setTimeout(async () => {
|
||||
await this.controller.reload(optionKey, this.controller.options);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Custom filters
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
selectJournal(journal) {
|
||||
if (journal.model === "account.journal.group") {
|
||||
const wasSelected = journal.selected;
|
||||
this.ToggleSelectedJournal(journal);
|
||||
this.controller.options.__journal_group_action = {
|
||||
action: wasSelected ? "remove" : "add",
|
||||
id: parseInt(journal.id),
|
||||
};
|
||||
// Toggle the selected status after the action is set
|
||||
journal.selected = !wasSelected;
|
||||
} else {
|
||||
journal.selected = !journal.selected;
|
||||
}
|
||||
this.applyFilters("journals");
|
||||
}
|
||||
|
||||
ToggleSelectedJournal(selectedJournal) {
|
||||
if (selectedJournal.selected) {
|
||||
this.controller.options.journals.forEach((journal) => {
|
||||
journal.selected = false;
|
||||
});
|
||||
} else {
|
||||
this.controller.options.journals.forEach((journal) => {
|
||||
journal.selected = selectedJournal.journals.includes(journal.id) && journal.model === "account.journal";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
unfoldCompanyJournals(selectedCompany) {
|
||||
let inSelectedCompanySection = false;
|
||||
for (const journal of this.controller.options.journals) {
|
||||
if (journal.id === "divider" && journal.model === "res.company") {
|
||||
if (journal.name === selectedCompany.name) {
|
||||
journal.unfolded = !journal.unfolded;
|
||||
inSelectedCompanySection = true;
|
||||
} else if (inSelectedCompanySection) {
|
||||
break; // Reached another company divider, exit the loop
|
||||
}
|
||||
}
|
||||
if (inSelectedCompanySection && journal.model === "account.journal") {
|
||||
journal.visible = !journal.visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async filterVariant(reportId) {
|
||||
this.controller.saveSessionOptions({
|
||||
...this.controller.options,
|
||||
selected_variant_id: reportId,
|
||||
sections_source_id: reportId,
|
||||
});
|
||||
const cacheKey = this.controller.getCacheKey(reportId, reportId);
|
||||
// if the variant hasn't been loaded yet, set up the call number
|
||||
if (!(cacheKey in this.controller.loadingCallNumberByCacheKey)) {
|
||||
this.controller.incrementCallNumber(cacheKey);
|
||||
}
|
||||
await this.controller.displayReport(reportId);
|
||||
}
|
||||
|
||||
async filterTaxUnit(taxUnit) {
|
||||
await this.filterClicked({ optionKey: "tax_unit", optionValue: taxUnit.id});
|
||||
this.controller.saveSessionOptions(this.controller.options);
|
||||
|
||||
// force the company to those impacted by the tax units, the reload will be force by this function
|
||||
this.companyService.setCompanies(taxUnit.company_ids);
|
||||
}
|
||||
|
||||
async toggleHideZeroLines() {
|
||||
// Avoid calling the database when this filter is toggled; as the exact same lines would be returned; just reassign visibility.
|
||||
await this.controller.toggleOption("hide_0_lines", false);
|
||||
|
||||
this.controller.saveSessionOptions(this.controller.options);
|
||||
this.controller.setLineVisibility(this.controller.lines);
|
||||
}
|
||||
|
||||
async toggleHorizontalSplit() {
|
||||
await this.controller.toggleOption("horizontal_split", false);
|
||||
this.controller.saveSessionOptions(this.controller.options);
|
||||
}
|
||||
|
||||
async filterRoundingUnit(rounding) {
|
||||
await this.controller.updateOption('rounding_unit', rounding, false);
|
||||
|
||||
this.controller.saveSessionOptions(this.controller.options);
|
||||
|
||||
this.controller.lines = await this.controller.orm.call(
|
||||
"account.report",
|
||||
"format_column_values",
|
||||
[
|
||||
this.controller.options,
|
||||
this.controller.lines,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async selectHorizontalGroup(horizontalGroupId) {
|
||||
if (horizontalGroupId === this.controller.options.selected_horizontal_group_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isBudgetSelected) {
|
||||
this.notification.add(
|
||||
_t("It's not possible to select a budget with the horizontal group feature."),
|
||||
{
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.filterClicked({ optionKey: "selected_horizontal_group_id", optionValue: horizontalGroupId, reload: true});
|
||||
}
|
||||
|
||||
selectBudget(budget) {
|
||||
if (this.isHorizontalGroupSelected) {
|
||||
this.notification.add(
|
||||
_t("It's not possible to select a horizontal group with the budget feature."),
|
||||
{
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
budget.selected = !budget.selected;
|
||||
this.applyFilters( 'budgets')
|
||||
}
|
||||
|
||||
async createBudget() {
|
||||
const budgetName = this.budgetName.value.trim();
|
||||
if (!budgetName.length) {
|
||||
this.budgetName.invalid = true;
|
||||
this.notification.add(_t("Please enter a valid budget name."), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const createdId = await this.orm.call("account.report.budget", "create", [
|
||||
{ name: budgetName },
|
||||
]);
|
||||
this.budgetName.value = "";
|
||||
this.budgetName.invalid = false;
|
||||
const options = this.controller.options;
|
||||
this.controller.reload("budgets", {
|
||||
...options,
|
||||
budgets: [
|
||||
...options.budgets,
|
||||
// Selected by default if we don't have any horizontal group selected
|
||||
{ id: createdId, selected: !this.isHorizontalGroupSelected },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
.account_report_filter {
|
||||
&.date {
|
||||
.date_filter:hover .btn_previous_date,
|
||||
.date_filter:hover .btn_next_date:not(:disabled) {
|
||||
color: $o-gray-600;
|
||||
}
|
||||
.filter_name { width: 120px }
|
||||
.time_text { width: 120px }
|
||||
.btn_previous_date, .btn_next_date {
|
||||
background: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: $o-gray-300;
|
||||
}
|
||||
.dropdown-item.focus {
|
||||
background: transparent;
|
||||
&:hover { background: rgba($o-black, 0.08) }
|
||||
}
|
||||
}
|
||||
|
||||
&.date, &.comparison {
|
||||
.dropdown-item.date {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.o_datetime_input {
|
||||
display: inline;
|
||||
width: 86px;
|
||||
margin: 0 8px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.comparison, &.intervals {
|
||||
.dropdown-item.period, .dropdown-item.interval {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&.order:hover {
|
||||
background-color: unset;
|
||||
color: unset;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input[type=number] {
|
||||
display: inline;
|
||||
width: 86px;
|
||||
margin: 0 8px;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--o-input-border-color);
|
||||
text-align: center;
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item.date {
|
||||
.o_datetime_input {
|
||||
display: inline;
|
||||
width: 86px;
|
||||
margin: 0 8px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item.link {
|
||||
cursor: default;
|
||||
|
||||
&.focus, &.selected:hover, &.selected:focus { background-color: inherit; }
|
||||
}
|
||||
}
|
||||
|
||||
&.analytic, &.analytic_groupby, &.partner {
|
||||
.dropdown {
|
||||
display: inherit;
|
||||
}
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
label { width: 60px }
|
||||
}
|
||||
.dropdown-item.focus, .dropdown-item.selected:hover, .dropdown-item.selected:focus { background-color: inherit; }
|
||||
.o_input_dropdown { width: 100% }
|
||||
.o_field_tags {
|
||||
margin-left: 8px;
|
||||
flex-direction: column;
|
||||
flex-grow: 9;
|
||||
|
||||
.o_tag { width: fit-content }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportFilters">
|
||||
<t t-call="{{ env.template('AccountReportFilters') }}"/>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportFiltersCustomizable">
|
||||
<!-- Date filter -->
|
||||
<t t-if="'date' in controller.options">
|
||||
<div id="filter_date" class="filter_date">
|
||||
<t t-call="fusion_accounting.AccountReportFilterDate"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Comparison filter -->
|
||||
<t t-if="controller.filters.show_period_comparison">
|
||||
<div id="filter_comparison" class="filter_comparison">
|
||||
<t t-call="fusion_accounting.AccountReportFilterComparison"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Journal filter -->
|
||||
<t t-if="'journals' in controller.options">
|
||||
<div id="filter_journal">
|
||||
<t t-call="fusion_accounting.AccountReportFilterJournal"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Account type filter -->
|
||||
<t t-if="'account_type' in controller.options">
|
||||
<div id="filter_account_type">
|
||||
<t t-call="fusion_accounting.AccountReportFilterAccountType"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Analytic filter -->
|
||||
<t t-if="controller.groups.analytic_accounting and controller.filters.show_analytic">
|
||||
<div id="filter_analytic" class="filter_analytic">
|
||||
<t t-call="fusion_accounting.AccountReportFilterAnalytic"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Analytic groupby filter -->
|
||||
<t t-if="hasAnalyticGroupbyFilter">
|
||||
<div id="filter_analytic_groupby" class="filter_analytic_groupby">
|
||||
<t t-call="fusion_accounting.AccountReportFilterAnalyticGroupby"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Horizontal groups filter -->
|
||||
<t t-if="controller.options.available_horizontal_groups.length > 0">
|
||||
<div id="filter_horizontal_groups">
|
||||
<t t-call="fusion_accounting.AccountReportFilterHorizontalGroups"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Partner filter -->
|
||||
<t t-if="'partner' in controller.options">
|
||||
<div id="filter_partner" class="filter_partner">
|
||||
<t t-call="fusion_accounting.AccountReportFilterPartner"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Extra options filter -->
|
||||
<t t-if="hasExtraOptionsFilter">
|
||||
<div id="filter_extra_options">
|
||||
<t t-call="fusion_accounting.AccountReportFilterExtraOptions"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Variant filter -->
|
||||
<t t-if="controller.options.available_variants.length > 1">
|
||||
<div id="filter_variant">
|
||||
<t t-call="fusion_accounting.AccountReportFilterVariant"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Tax unit filter -->
|
||||
<t t-if="'available_tax_units' in this.controller.options and controller.options.available_tax_units.length > 0">
|
||||
<div id="filter_tax_unit">
|
||||
<t t-call="fusion_accounting.AccountReportFilterTaxUnit"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Fiscal position filter -->
|
||||
<t t-if="hasFiscalPositionFilter">
|
||||
<div id="filter_fiscal_position">
|
||||
<t t-call="fusion_accounting.AccountReportFilterFiscalPosition"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Budget filter -->
|
||||
<t t-if="'budgets' in controller.options">
|
||||
<div id="filter_budget">
|
||||
<t t-call="fusion_accounting.AccountReportFilterBudget"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- User defined filter on journal items -->
|
||||
<t t-if="'aml_ir_filters' in controller.options and controller.options.aml_ir_filters.length > 0">
|
||||
<div id="filter_aml_ir_filters">
|
||||
<t t-call="fusion_accounting.AccountReportAmlIrFilters"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div id="filter_rounding_unit">
|
||||
<t t-call="fusion_accounting.AccountReportFilterRoundingUnit"/>
|
||||
</div>
|
||||
|
||||
<t t-if="env.debug">
|
||||
<div id="filter_configuration" class="o-dropdown dropdown o-dropdown--no-caret">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
t-on-click="(ev) => this.controller.reportAction(ev, 'action_open_report_form', {}, true)"
|
||||
>
|
||||
<i class="fa fa-cogs me-1"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,123 @@
|
||||
// Fusion Accounting - Account Report Header
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { useService } from "@web/core/utils/hooks"
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportHeader - Renders the report table header with column labels,
|
||||
* sortable columns, and horizontal group navigation controls.
|
||||
*/
|
||||
export class AccountReportHeader extends Component {
|
||||
static template = "fusion_accounting.AccountReportHeader";
|
||||
static props = {};
|
||||
static components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.controller = useState(this.env.controller);
|
||||
}
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Headers
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get columnHeaders() {
|
||||
let columnHeaders = [];
|
||||
|
||||
this.controller.options.column_headers.forEach((columnHeader, columnHeaderIndex) => {
|
||||
let columnHeadersRow = [];
|
||||
|
||||
for (let i = 0; i < this.controller.columnHeadersRenderData.level_repetitions[columnHeaderIndex]; i++) {
|
||||
columnHeadersRow = [ ...columnHeadersRow, ...columnHeader];
|
||||
}
|
||||
|
||||
columnHeaders.push(columnHeadersRow);
|
||||
});
|
||||
|
||||
return columnHeaders;
|
||||
}
|
||||
|
||||
columnHeadersColspan(column_index, header, compactOffset = 0) {
|
||||
let colspan = header.colspan || this.controller.columnHeadersRenderData.level_colspan[column_index]
|
||||
// In case of we need the total column for horizontal we need to increase the colspan of the first row
|
||||
if(this.controller.options.show_horizontal_group_total && column_index === 0) {
|
||||
colspan += 1;
|
||||
}
|
||||
return colspan;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Subheaders
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get subheaders() {
|
||||
const columns = JSON.parse(JSON.stringify(this.controller.options.columns));
|
||||
const columnsPerGroupKey = {};
|
||||
|
||||
columns.forEach((column) => {
|
||||
columnsPerGroupKey[`${column.column_group_key}_${column.expression_label}`] = column;
|
||||
});
|
||||
|
||||
return this.controller.lines[0].columns.map((column) => {
|
||||
if (columnsPerGroupKey[`${column.column_group_key}_${column.expression_label}`]) {
|
||||
return columnsPerGroupKey[`${column.column_group_key}_${column.expression_label}`];
|
||||
} else {
|
||||
return {
|
||||
expression_label: "",
|
||||
sortable: false,
|
||||
name: "",
|
||||
colspan: 1,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Custom subheaders
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get customSubheaders() {
|
||||
let customSubheaders = [];
|
||||
|
||||
this.controller.columnHeadersRenderData.custom_subheaders.forEach(customSubheader => {
|
||||
customSubheaders.push(customSubheader);
|
||||
});
|
||||
|
||||
return customSubheaders;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Sortable
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
sortableClasses(columIndex) {
|
||||
switch (this.controller.linesCurrentOrderByColumn(columIndex)) {
|
||||
case "ASC":
|
||||
return "fa fa-long-arrow-up";
|
||||
case "DESC":
|
||||
return "fa fa-long-arrow-down";
|
||||
default:
|
||||
return "fa fa-arrows-v";
|
||||
}
|
||||
}
|
||||
|
||||
async sortLinesByColumn(columnIndex, column) {
|
||||
if (column.sortable) {
|
||||
switch (this.controller.linesCurrentOrderByColumn(columnIndex)) {
|
||||
case "default":
|
||||
await this.controller.sortLinesByColumnAsc(columnIndex);
|
||||
break;
|
||||
case "ASC":
|
||||
await this.controller.sortLinesByColumnDesc(columnIndex);
|
||||
break;
|
||||
case "DESC":
|
||||
this.controller.sortLinesByDefault();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid value: ${ this.controller.linesCurrentOrderByColumn(columnIndex) }`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportHeader">
|
||||
<t t-call="{{ env.template('AccountReportHeader') }}"/>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportHeaderCustomizable">
|
||||
<thead id="table_header" class="sticky">
|
||||
<!-- Headers -->
|
||||
<t t-foreach="columnHeaders" t-as="columnHeader" t-key="columnHeader_index">
|
||||
<tr>
|
||||
<!-- First empty column -->
|
||||
<th>
|
||||
<t t-if="columnHeader_first and controller.options.companies.length > 1">
|
||||
<span class="companies_header" t-out="controller.options.companies.map(company => company.name).join(', ')"/>
|
||||
</t>
|
||||
</th>
|
||||
|
||||
<!-- Other columns -->
|
||||
<t t-foreach="columnHeader" t-as="header" t-key="header_index">
|
||||
<th
|
||||
class="column_header"
|
||||
t-att-colspan="this.columnHeadersColspan(columnHeader_index, header)"
|
||||
>
|
||||
<t t-out="header.name"/>
|
||||
</th>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.options.show_horizontal_group_total">
|
||||
<t t-if="!columnHeader_first">
|
||||
<th class="column_header">
|
||||
<t t-out="controller.options.available_horizontal_groups.find(group => group.id === controller.options.selected_horizontal_group_id)?.name"/>
|
||||
</th>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Growth comparison column -->
|
||||
<t t-if="controller.needsColumnPercentComparison">
|
||||
<!-- We only want to display it once on the first row -->
|
||||
<t t-if="columnHeader_first">
|
||||
<th class="column_percent_comparison_header">
|
||||
<i class="fa fa-percent"/>
|
||||
</th>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Debug column -->
|
||||
<t t-if="controller.hasDebugColumn">
|
||||
<!-- We only want to display it once on the first row -->
|
||||
<t t-if="columnHeader_first">
|
||||
<th class="debug_header">
|
||||
<i class="fa fa-bug"/>
|
||||
</th>
|
||||
</t>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
|
||||
<!-- Custom subheaders -->
|
||||
<t t-if="controller.hasCustomSubheaders">
|
||||
<tr>
|
||||
<!-- First empty column -->
|
||||
<th/>
|
||||
|
||||
<!-- Other columns -->
|
||||
<t t-foreach="customSubheaders" t-as="customSubheader" t-key="customSubheader_index">
|
||||
<th
|
||||
t-att-colspan="customSubheader.colspan || 1"
|
||||
t-att-data-expression_label="customSubheader.expression_label"
|
||||
>
|
||||
<t t-out="customSubheader.name"/>
|
||||
</th>
|
||||
</t>
|
||||
|
||||
<!-- Debug column -->
|
||||
<t t-if="controller.hasDebugColumn">
|
||||
<!-- Empty to prevent a gap beneath the debug label -->
|
||||
<th/>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
|
||||
<!-- Subheaders -->
|
||||
<tr>
|
||||
<!-- First empty column -->
|
||||
<th/>
|
||||
|
||||
<!-- Other columns -->
|
||||
<t t-foreach="subheaders" t-as="subheader" t-key="subheader_index">
|
||||
<th
|
||||
t-att-colspan="subheader.colspan || 1"
|
||||
t-att-data-expression_label="subheader.expression_label"
|
||||
>
|
||||
<!-- Sortable -->
|
||||
<t t-if="subheader.sortable">
|
||||
<button
|
||||
class="btn btn_sortable"
|
||||
t-on-click="() => this.sortLinesByColumn(subheader_index, subheader)"
|
||||
>
|
||||
<i t-att-class="sortableClasses(subheader_index)"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
<t t-if="subheader.template">
|
||||
<t t-call="{{ subheader.template }}"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-out="subheader.name"/>
|
||||
</t>
|
||||
</th>
|
||||
</t>
|
||||
|
||||
<!-- Debug column -->
|
||||
<t t-if="controller.hasDebugColumn">
|
||||
<!-- Empty to prevent a gap beneath the debug label -->
|
||||
<th/>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.options.show_horizontal_group_total">
|
||||
<th>
|
||||
<!-- Since we have only one column possible with horizontal group totals taking the first element is ok -->
|
||||
<t t-out="subheaders[0].name"/>
|
||||
</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,149 @@
|
||||
// Fusion Accounting - Account Report Line
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
import { AccountReportDebugPopover } from "@fusion_accounting/components/account_report/line/popover/debug_popover";
|
||||
import { AccountReportLineCellEditable } from "@fusion_accounting/components/account_report/line_cell_editable/line_cell_editable";
|
||||
|
||||
/**
|
||||
* AccountReportLine - Renders a single row in the report table.
|
||||
* Handles line-level interactions including expand/collapse, selection,
|
||||
* debug info display, and inline cell editing delegation.
|
||||
*/
|
||||
export class AccountReportLine extends Component {
|
||||
static template = "fusion_accounting.AccountReportLine";
|
||||
static props = {
|
||||
lineIndex: Number,
|
||||
line: Object,
|
||||
};
|
||||
static components = {
|
||||
AccountReportLineCellEditable,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.popover = useService("popover");
|
||||
this.controller = useState(this.env.controller);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Line
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get lineClasses() {
|
||||
let classes = ('level' in this.props.line) ? `line_level_${this.props.line.level}` : 'line_level_default';
|
||||
|
||||
if (!this.props.line.visible || this.isHiddenBySearchFilter())
|
||||
classes += " d-none";
|
||||
|
||||
if (this.props.line.unfolded && this.hasVisibleChild())
|
||||
classes += " unfolded";
|
||||
|
||||
if (this.controller.isTotalLine(this.props.lineIndex))
|
||||
classes += " total";
|
||||
|
||||
if (this.props.line.class)
|
||||
classes += ` ${this.props.line.class}`;
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
hasVisibleChild() {
|
||||
let nextLineIndex = this.props.lineIndex + 1;
|
||||
|
||||
while (this.controller.isNextLineChild(nextLineIndex, this.props.line['id'])) {
|
||||
if (this.controller.lines[nextLineIndex].visible && !this.isHiddenBySearchFilter(this.controller.lines[nextLineIndex].id))
|
||||
return true;
|
||||
|
||||
nextLineIndex += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Growth comparison
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get growthComparisonClasses() {
|
||||
let classes = "text-end";
|
||||
|
||||
switch(this.props.line.column_percent_comparison_data.mode) {
|
||||
case "green":
|
||||
classes += " text-success";
|
||||
break;
|
||||
case "muted":
|
||||
classes += " muted";
|
||||
break;
|
||||
case "red":
|
||||
classes += " text-danger";
|
||||
break;
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Total Horizontal Group
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get HorizontalGroupTotalClasses() {
|
||||
let classes = "text-end";
|
||||
switch(Math.sign(this.props.line.horizontal_group_total_data?.no_format)) {
|
||||
case 1:
|
||||
break;
|
||||
case 0:
|
||||
classes += " muted";
|
||||
break;
|
||||
case -1:
|
||||
classes += " text-danger";
|
||||
break;
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Search
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
isHiddenBySearchFilter(lineId = null) {
|
||||
// If no lineId is provided, this will execute on the current line object
|
||||
// Otherwise, it will execute on the given lineId
|
||||
lineId ||= this.props.line.id;
|
||||
|
||||
if (!("lines_searched" in this.controller))
|
||||
return false;
|
||||
|
||||
for (let searchLineId of this.controller.lines_searched)
|
||||
if (this.controller.isLineRelatedTo(searchLineId, lineId) || lineId === searchLineId)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Debug popover
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
showDebugPopover(ev) {
|
||||
const close = () => {
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
|
||||
if (this.popoverCloseFn)
|
||||
close();
|
||||
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
ev.currentTarget,
|
||||
AccountReportDebugPopover,
|
||||
{
|
||||
expressionsDetail: JSON.parse(this.props.line.debug_popup_data).expressions_detail,
|
||||
onClose: close,
|
||||
},
|
||||
{
|
||||
closeOnClickAway: true,
|
||||
position: localization.direction === "rtl" ? "left" : "right",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportLine">
|
||||
<t t-call="{{ env.template('AccountReportLine') }}"/>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportLineCustomizable">
|
||||
<!-- Adds an empty row above line with level 0 to add some spacing (it is the easiest and cleanest way) -->
|
||||
<t t-if="props.line.level === 0">
|
||||
<tr class="empty">
|
||||
<td/>
|
||||
<t t-foreach="props.line.columns" t-as="cell" t-key="cell_index">
|
||||
<td/>
|
||||
</t>
|
||||
<t t-if="controller.needsColumnPercentComparison">
|
||||
<td/>
|
||||
</t>
|
||||
<t t-if="controller.hasDebugColumn">
|
||||
<td/>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
|
||||
<tr
|
||||
data-id="line"
|
||||
t-att-class="lineClasses"
|
||||
>
|
||||
<!-- Name column -->
|
||||
<t t-component="env.component('AccountReportLineName')" t-props="{ lineIndex: props.lineIndex, line: props.line }"/>
|
||||
|
||||
<!-- Value columns -->
|
||||
<t t-foreach="props.line.columns" t-as="cell" t-key="cell_index">
|
||||
<!-- Condition for budget column only, should be changed in the future when we'll remove popover for editable cell -->
|
||||
<t t-if="cell.column_group_key?.includes('compute_budget')">
|
||||
<AccountReportLineCellEditable t-props="{ line: props.line, cell: cell }"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-component="env.component('AccountReportLineCell')" t-props="{ line: props.line, cell: cell }"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Growth comparison column -->
|
||||
<t t-if="controller.needsColumnPercentComparison">
|
||||
<t t-if="controller.lineHasGrowthComparisonData(props.lineIndex)">
|
||||
<td t-att-class="growthComparisonClasses">
|
||||
<t t-out="props.line.column_percent_comparison_data.name"/>
|
||||
</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Debug column -->
|
||||
<t t-if="controller.hasDebugColumn">
|
||||
<td class="text-center">
|
||||
<t t-if="props.line.code">
|
||||
<t t-out="props.line.code"/>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.lineHasDebugData(props.lineIndex)">
|
||||
<button class="btn btn_debug" t-on-click="(ev) => this.showDebugPopover(ev)">
|
||||
<i class="fa fa-info-circle"/>
|
||||
</button>
|
||||
</t>
|
||||
</td>
|
||||
</t>
|
||||
|
||||
<t t-if="controller.options.show_horizontal_group_total">
|
||||
<td t-att-class="HorizontalGroupTotalClasses">
|
||||
<t t-out="props.line.horizontal_group_total_data?.name"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,17 @@
|
||||
// Fusion Accounting - Account Report Debug Popover
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportDebugPopover - Developer popover showing expression
|
||||
* details and computation breakdown for a given report line.
|
||||
*/
|
||||
export class AccountReportDebugPopover extends Component {
|
||||
static template = "fusion_accounting.AccountReportDebugPopover";
|
||||
static props = {
|
||||
close: Function,
|
||||
expressionsDetail: Array,
|
||||
onClose: Function,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportDebugPopover">
|
||||
<div class="account_report_popover_debug">
|
||||
<t t-foreach="props.expressionsDetail" t-as="expressionEngineAndTotals" t-key="expressionEngineAndTotals_index">
|
||||
<t t-set="expressionEngine" t-value="expressionEngineAndTotals[0]"/>
|
||||
<t t-set="expressionTotals" t-value="expressionEngineAndTotals[1]"/>
|
||||
|
||||
<div class="line_debug">
|
||||
<span>Engine</span>
|
||||
<span class="fw-bold" t-out="expressionEngine"/>
|
||||
</div>
|
||||
|
||||
<t t-foreach="expressionTotals" t-as="labelAndInfo" t-key="labelAndInfo_index">
|
||||
<t t-set="expressionLabel" t-value="labelAndInfo[0]"/>
|
||||
<t t-set="expressionInfo" t-value="labelAndInfo[1]"/>
|
||||
|
||||
<div class="line_debug">
|
||||
<span>Label</span>
|
||||
<span t-out="expressionLabel"/>
|
||||
</div>
|
||||
|
||||
<div class="line_debug">
|
||||
<span>Formula</span>
|
||||
<code t-out="expressionInfo.formula"/>
|
||||
</div>
|
||||
|
||||
<t t-if="expressionInfo.subformula">
|
||||
<div class="line_debug">
|
||||
<span>Subformula</span>
|
||||
<code t-out="expressionInfo.subformula"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="line_debug">
|
||||
<span>Value</span>
|
||||
<span t-out="expressionInfo.value"/>
|
||||
</div>
|
||||
|
||||
<t t-if="!labelAndInfo_last">
|
||||
<div class="totals_separator"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-if="!expressionEngineAndTotals_last">
|
||||
<div class="engine_separator"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,184 @@
|
||||
// Fusion Accounting - Account Report Line Cell
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { AccountReportCarryoverPopover } from "@fusion_accounting/components/account_report/line_cell/popover/carryover_popover";
|
||||
import { AccountReportEditPopover } from "@fusion_accounting/components/account_report/line_cell/popover/edit_popover";
|
||||
|
||||
import { Component, markup, useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportLineCell - Renders an individual cell within a report line.
|
||||
* Supports display of monetary values, percentages, and dates with
|
||||
* optional edit and carryover popover interactions.
|
||||
*/
|
||||
export class AccountReportLineCell extends Component {
|
||||
static template = "fusion_accounting.AccountReportLineCell";
|
||||
static props = {
|
||||
line: {
|
||||
type: Object,
|
||||
optional: true,
|
||||
},
|
||||
cell: Object,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.popover = useService("popover");
|
||||
this.controller = useState(this.env.controller);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
isNumeric(type) {
|
||||
return ['float', 'integer', 'monetary', 'percentage'].includes(type);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Attributes
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get cellClasses() {
|
||||
if (this.props.cell.comparison_mode) {
|
||||
return this.comparisonClasses;
|
||||
}
|
||||
|
||||
let classes = "";
|
||||
|
||||
if (this.props.cell.auditable)
|
||||
classes += " auditable";
|
||||
|
||||
if (this.props.cell.figure_type === 'date')
|
||||
classes += " date";
|
||||
|
||||
if (this.props.cell.figure_type === 'string')
|
||||
classes += " text";
|
||||
|
||||
if (this.isNumeric(this.props.cell.figure_type)) {
|
||||
classes += " numeric text-end";
|
||||
|
||||
if (this.props.cell.no_format !== undefined)
|
||||
switch (Math.sign(this.props.cell.no_format)) {
|
||||
case 1:
|
||||
break;
|
||||
case 0:
|
||||
case -0:
|
||||
classes += " muted";
|
||||
break;
|
||||
case -1:
|
||||
classes += " text-danger";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.cell.class)
|
||||
classes += ` ${this.props.cell.class}`;
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Audit
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
async audit() {
|
||||
const auditAction = await this.orm.call(
|
||||
"account.report",
|
||||
"dispatch_report_action",
|
||||
[
|
||||
this.controller.options.report_id,
|
||||
this.controller.options,
|
||||
"action_audit_cell",
|
||||
{
|
||||
report_line_id: this.props.cell.report_line_id,
|
||||
expression_label: this.props.cell.expression_label,
|
||||
calling_line_dict_id: this.props.line.id,
|
||||
column_group_key: this.props.cell.column_group_key,
|
||||
},
|
||||
],
|
||||
{
|
||||
context: this.controller.context,
|
||||
}
|
||||
);
|
||||
if (auditAction.help) {
|
||||
auditAction.help = markup(auditAction.help);
|
||||
}
|
||||
|
||||
return this.action.doAction(auditAction);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Edit Popover
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
editPopover(ev) {
|
||||
const close = () => {
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
|
||||
if (this.popoverCloseFn)
|
||||
close();
|
||||
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
ev.currentTarget,
|
||||
AccountReportEditPopover,
|
||||
{
|
||||
line_id: this.props.line.id,
|
||||
cell: this.props.cell,
|
||||
controller: this.controller,
|
||||
onClose: close,
|
||||
},
|
||||
{
|
||||
closeOnClickAway: true,
|
||||
position: localization.direction === "rtl" ? "bottom" : "left",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Carryover popover
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
carryoverPopover(ev) {
|
||||
if (this.popoverCloseFn) {
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
ev.currentTarget,
|
||||
AccountReportCarryoverPopover,
|
||||
{
|
||||
carryoverData: JSON.parse(this.props.cell.info_popup_data),
|
||||
options: this.controller.options,
|
||||
context: this.controller.context,
|
||||
},
|
||||
{
|
||||
closeOnClickAway: true,
|
||||
position: localization.direction === "rtl" ? "bottom" : "right",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Comparison cell
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get comparisonClasses() {
|
||||
let classes = "text-end";
|
||||
|
||||
switch (this.props.cell.comparison_mode) {
|
||||
case "green":
|
||||
classes += " text-success";
|
||||
break;
|
||||
case "muted":
|
||||
classes += " muted";
|
||||
break;
|
||||
case "red":
|
||||
classes += " text-danger";
|
||||
break;
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportLineCell">
|
||||
<t t-call="{{ env.template('AccountReportLineCell') }}"/>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportLineCellCustomizable">
|
||||
<td
|
||||
data-id="line_cell"
|
||||
class="line_cell"
|
||||
t-att-class="cellClasses"
|
||||
t-att-data-expression_label="props.cell.expression_label"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<t t-if="!controller.filters.show_totals or !props.line.unfolded">
|
||||
<t t-if="props.cell.info_popup_data">
|
||||
<a
|
||||
class="fa fa-question-circle me-2"
|
||||
t-on-click="(ev) => this.carryoverPopover(ev)"
|
||||
/>
|
||||
</t>
|
||||
|
||||
<t t-if="props.cell.edit_popup_data">
|
||||
<button
|
||||
class="btn btn_edit me-auto"
|
||||
t-on-click="(ev) => this.editPopover(ev)"
|
||||
>
|
||||
<i class="fa fa-pencil"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
<div class="content">
|
||||
<t t-if="props.cell.auditable">
|
||||
<a t-on-click="() => this.audit()">
|
||||
<t t-component="env.component('AccountReportEllipsis')" t-props="{
|
||||
name: props.cell.name?.toString(),
|
||||
no_format: props.cell.no_format,
|
||||
type: props.cell.figure_type,
|
||||
maxCharacters: 50,
|
||||
}"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-component="env.component('AccountReportEllipsis')" t-props="{
|
||||
name: props.cell.name?.toString(),
|
||||
no_format: props.cell.no_format,
|
||||
type: props.cell.figure_type,
|
||||
maxCharacters: 50,
|
||||
}"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,44 @@
|
||||
// Fusion Accounting - Account Report Carryover Popover
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportCarryoverPopover - Displays carryover balance details
|
||||
* and allows adjustment of carryover values between reporting periods.
|
||||
*/
|
||||
export class AccountReportCarryoverPopover extends Component {
|
||||
static template = "fusion_accounting.AccountReportCarryoverPopover";
|
||||
static props = {
|
||||
close: Function,
|
||||
carryoverData: Object,
|
||||
options: Object,
|
||||
context: Object,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
//
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async viewCarryoverLinesAction(expressionId, columnGroupKey) {
|
||||
const viewCarryoverLinesAction = await this.orm.call(
|
||||
"account.report.expression",
|
||||
"action_view_carryover_lines",
|
||||
[
|
||||
expressionId,
|
||||
this.props.options,
|
||||
columnGroupKey,
|
||||
],
|
||||
{
|
||||
context: this.props.context,
|
||||
}
|
||||
);
|
||||
this.props.close()
|
||||
return this.actionService.doAction(viewCarryoverLinesAction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportCarryoverPopover">
|
||||
<table class="carryover_popover">
|
||||
<t t-if="props.carryoverData.carryover || props.carryoverData.applied_carryover">
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Carryover</strong>
|
||||
</td>
|
||||
<td/>
|
||||
</tr>
|
||||
|
||||
<t t-if="props.carryoverData.applied_carryover">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<span>
|
||||
<strong t-out="props.carryoverData.applied_carryover"/> were carried over to this line from previous period.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
|
||||
<t t-if="props.carryoverData.carryover">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<t t-if="props.carryoverData.carryover_target">
|
||||
<span>
|
||||
<strong t-out="props.carryoverData.carryover"/> will be carried over to <strong t-out="props.carryoverData.carryover_target"/> in the next period.
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span>
|
||||
<strong t-out="props.carryoverData.carryover"/> will be carried over to this line in the next period.
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
|
||||
<t t-if="props.carryoverData.allow_carryover_audit">
|
||||
<tr>
|
||||
<td/>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
t-on-click="() => this.viewCarryoverLinesAction(props.carryoverData.expression_id, props.carryoverData.column_group_key)"
|
||||
>
|
||||
View Carryover Lines
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</t>
|
||||
</table>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,69 @@
|
||||
// Fusion Accounting - Account Report Edit Popover
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { useAutofocus, useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { Component, useRef } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportEditPopover - Inline editing popover for modifying
|
||||
* report cell values directly within the report view.
|
||||
*/
|
||||
export class AccountReportEditPopover extends Component {
|
||||
static template = "fusion_accounting.AccountReportEditPopover";
|
||||
static props = {
|
||||
line_id: String,
|
||||
cell: Object,
|
||||
controller: Object,
|
||||
onClose: Function,
|
||||
close: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
|
||||
if (this.props.cell.figure_type === 'boolean') {
|
||||
this.booleanTrue = useRef("booleanTrue");
|
||||
this.booleanFalse = useRef("booleanFalse");
|
||||
} else {
|
||||
this.input = useRef("input");
|
||||
useAutofocus({ refName: "input" });
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Edit
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
async edit() {
|
||||
let editValue;
|
||||
const editPopupData = JSON.parse(this.props.cell.edit_popup_data);
|
||||
|
||||
if (this.props.cell.figure_type === 'boolean')
|
||||
editValue = Number(this.booleanTrue.el.checked && !this.booleanFalse.el.checked);
|
||||
else
|
||||
editValue = this.input.el.value;
|
||||
|
||||
const res = await this.orm.call(
|
||||
"account.report",
|
||||
"action_modify_manual_value",
|
||||
[
|
||||
this.props.controller.options.report_id,
|
||||
this.props.line_id,
|
||||
this.props.controller.options,
|
||||
editPopupData.column_group_key,
|
||||
editValue,
|
||||
editPopupData.target_expression_id,
|
||||
editPopupData.rounding,
|
||||
this.props.controller.columnGroupsTotals,
|
||||
],
|
||||
{
|
||||
context: this.props.controller.context,
|
||||
}
|
||||
);
|
||||
|
||||
this.props.controller.lines = res.lines;
|
||||
this.props.controller.columnGroupsTotals = res.column_groups_totals;
|
||||
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportEditPopover">
|
||||
<div class="account_report_popover_edit">
|
||||
<t t-if="props.cell.figure_type === 'boolean'">
|
||||
<div class="edit_popover_boolean">
|
||||
<!-- Yes -->
|
||||
<input
|
||||
id="edit_popover_boolean_true"
|
||||
name="edit_popover_boolean"
|
||||
type="radio"
|
||||
value="1"
|
||||
t-att-checked="props.cell.no_format"
|
||||
t-ref="booleanTrue"
|
||||
/>
|
||||
<label for="edit_popover_boolean_true">Yes</label>
|
||||
|
||||
<!-- No -->
|
||||
<input
|
||||
id="edit_popover_boolean_false"
|
||||
name="edit_popover_boolean"
|
||||
type="radio"
|
||||
value="0"
|
||||
t-att-checked="!props.cell.no_format"
|
||||
t-ref="booleanFalse"
|
||||
/>
|
||||
<label for="edit_popover_boolean_false">No</label>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="props.cell.figure_type === 'string'">
|
||||
<textarea class="edit_popover_string" rows="3" t-ref="input">
|
||||
<t t-out="props.cell.no_format"/>
|
||||
</textarea>
|
||||
</t>
|
||||
<t t-elif="props.cell.figure_type === 'percentage'">
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
t-ref="input"
|
||||
t-att-value="props.cell.no_format"
|
||||
/>
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="props.cell.figure_type === 'monetary' and props.cell.currency_symbol">
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
t-ref="input"
|
||||
t-att-value="props.cell.no_format"
|
||||
/>
|
||||
<span class="input-group-text">
|
||||
<t t-out="props.cell.currency_symbol"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
t-ref="input"
|
||||
t-att-value="props.cell.no_format"
|
||||
/>
|
||||
</t>
|
||||
|
||||
<button class="btn btn-sm mt-2" t-on-click="() => this.edit()">
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Fusion Accounting - Account Report Editable Line Cell
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { useRef, useState } from "@odoo/owl";
|
||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||
|
||||
import { AccountReportLineCell } from "@fusion_accounting/components/account_report/line_cell/line_cell";
|
||||
|
||||
export class AccountReportLineCellEditable extends AccountReportLineCell {
|
||||
static template = "fusion_accounting.AccountReportLineCellEditable";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.input = useRef("input");
|
||||
this.focused = useState({ value: false });
|
||||
|
||||
useHotkey(
|
||||
"Enter",
|
||||
(ev) => {
|
||||
ev.target.blur();
|
||||
},
|
||||
{ bypassEditableProtection: true }
|
||||
);
|
||||
}
|
||||
|
||||
get cellClasses() {
|
||||
let classes = super.cellClasses;
|
||||
if (this.hasEditPopupData) {
|
||||
classes += " editable-cell";
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
get hasEditPopupData() {
|
||||
return Boolean(this.props.cell?.edit_popup_data);
|
||||
}
|
||||
|
||||
async onChange() {
|
||||
if (!this.input.el.value.trim()) {
|
||||
return;
|
||||
}
|
||||
const editValue = this.input.el.value;
|
||||
const cellEditData = this.hasEditPopupData
|
||||
? JSON.parse(this.props.cell.edit_popup_data)
|
||||
: {};
|
||||
|
||||
const res = await this.orm.call(
|
||||
"account.report",
|
||||
"action_modify_manual_value",
|
||||
[
|
||||
this.controller.options.report_id,
|
||||
this.props.line.id,
|
||||
this.controller.options,
|
||||
cellEditData.column_group_key,
|
||||
editValue,
|
||||
cellEditData.target_expression_id,
|
||||
cellEditData.rounding,
|
||||
this.controller.columnGroupsTotals,
|
||||
],
|
||||
{
|
||||
context: this.controller.context,
|
||||
}
|
||||
);
|
||||
this.controller.lines = res.lines;
|
||||
this.controller.columnGroupsTotals = res.column_groups_totals;
|
||||
this.focused.value = false;
|
||||
}
|
||||
|
||||
get inputValue() {
|
||||
return this.focused.value ? this.props.cell.no_format : this.props.cell.name;
|
||||
}
|
||||
|
||||
onFocus() {
|
||||
this.focused.value = true;
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
if (JSON.stringify(this.props.cell.no_format) === this.input.el.value) {
|
||||
this.focused.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportLineCellEditable" t-inherit="fusion_accounting.AccountReportLineCellCustomizable" t-inherit-mode="primary">
|
||||
<xpath expr="//div[contains(@class, 'wrapper')]" position="replace">
|
||||
<t t-if="hasEditPopupData">
|
||||
<div class="content">
|
||||
<input type="text"
|
||||
class="o_input text-end"
|
||||
t-ref="input"
|
||||
t-on-change="onChange"
|
||||
t-on-focus="onFocus"
|
||||
t-on-blur="onBlur"
|
||||
t-att-value="inputValue"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">$0</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,179 @@
|
||||
// Fusion Accounting - Account Report Line Name
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { Component, useState, useRef } from "@odoo/owl";
|
||||
|
||||
import { AccountReportAnnotationsPopover } from "@fusion_accounting/components/account_report/line_name/popover/annotations_popover";
|
||||
|
||||
/**
|
||||
* AccountReportLineName - Renders the name column of a report line.
|
||||
* Handles line folding/unfolding, action menus, annotations popover,
|
||||
* and contextual actions like viewing underlying journal entries.
|
||||
*/
|
||||
export class AccountReportLineName extends Component {
|
||||
static template = "fusion_accounting.AccountReportLineName";
|
||||
static props = {
|
||||
lineIndex: Number,
|
||||
line: Object,
|
||||
};
|
||||
static components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.controller = useState(this.env.controller);
|
||||
this.annotationPopOver = usePopover(AccountReportAnnotationsPopover, {
|
||||
setActiveElement: false,
|
||||
position: "bottom",
|
||||
animation: false,
|
||||
closeOnClickAway: (target) => !target.closest(".annotation_popover"),
|
||||
});
|
||||
|
||||
this.lineNameCell = useRef("lineNameCell");
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Caret options
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
get caretOptions() {
|
||||
return this.controller.caretOptions[this.props.line.caret_options];
|
||||
}
|
||||
|
||||
get hasCaretOptions() {
|
||||
return this.caretOptions?.length > 0;
|
||||
}
|
||||
|
||||
async caretAction(caretOption) {
|
||||
const res = await this.orm.call(
|
||||
"account.report",
|
||||
"dispatch_report_action",
|
||||
[
|
||||
this.controller.options.report_id,
|
||||
this.controller.options,
|
||||
caretOption.action,
|
||||
{
|
||||
line_id: this.props.line.id,
|
||||
action_param: caretOption.action_param,
|
||||
},
|
||||
],
|
||||
{
|
||||
context: this.controller.context,
|
||||
}
|
||||
);
|
||||
|
||||
return this.action.doAction(res);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Classes
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get lineNameClasses() {
|
||||
let classes = "text";
|
||||
|
||||
if (this.props.line.unfoldable)
|
||||
classes += " unfoldable";
|
||||
|
||||
if (this.props.line.class)
|
||||
classes += ` ${ this.props.line.class }`;
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Action
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
async triggerAction() {
|
||||
const res = await this.orm.call(
|
||||
"account.report",
|
||||
"execute_action",
|
||||
[
|
||||
this.controller.options.report_id,
|
||||
this.controller.options,
|
||||
{
|
||||
id: this.props.line.id,
|
||||
actionId: this.props.line.action_id,
|
||||
},
|
||||
],
|
||||
{
|
||||
context: this.controller.context,
|
||||
}
|
||||
);
|
||||
|
||||
return this.action.doAction(res);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Load more
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
async loadMore() {
|
||||
const newLines = await this.orm.call(
|
||||
"account.report",
|
||||
"get_expanded_lines",
|
||||
[
|
||||
this.controller.options.report_id,
|
||||
this.controller.options,
|
||||
this.props.line.parent_id,
|
||||
this.props.line.groupby,
|
||||
this.props.line.expand_function,
|
||||
this.props.line.progress,
|
||||
this.props.line.offset,
|
||||
this.props.line.horizontal_split_side,
|
||||
],
|
||||
);
|
||||
|
||||
this.controller.setLineVisibility(newLines)
|
||||
if (this.controller.areLinesOrdered()) {
|
||||
this.controller.updateLinesOrderIndexes(this.props.lineIndex, newLines, true)
|
||||
}
|
||||
await this.controller.replaceLineWith(this.props.lineIndex, newLines);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Fold / Unfold
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
toggleFoldable() {
|
||||
if (this.props.line.unfoldable)
|
||||
if (this.props.line.unfolded)
|
||||
this.controller.foldLine(this.props.lineIndex);
|
||||
else
|
||||
this.controller.unfoldLine(this.props.lineIndex);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
// Annotation
|
||||
// -----------------------------------------------------------------------------------------------------------------
|
||||
get hasVisibleAnnotation() {
|
||||
return this.props.line.visible_annotations;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Annotation Popover
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async toggleAnnotationPopover() {
|
||||
if (this.annotationPopOver.isOpen) {
|
||||
this.annotationPopOver.close();
|
||||
} else {
|
||||
this.annotationPopOver.open(this.lineNameCell.el, {
|
||||
controller: this.controller,
|
||||
lineName: this,
|
||||
lineID: this.props.line.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async addAnnotation(ev) {
|
||||
this.annotationPopOver.open(this.lineNameCell.el, {
|
||||
controller: this.controller,
|
||||
lineName: this,
|
||||
isAddingAnnotation: true,
|
||||
lineID: this.props.line.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportLineName">
|
||||
<t t-call="{{ env.template('AccountReportLineName') }}"/>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportLineNameCustomizable">
|
||||
<td
|
||||
data-id="line_name"
|
||||
class="line_name"
|
||||
t-att-class="lineNameClasses"
|
||||
t-on-click="() => this.toggleFoldable()"
|
||||
t-att-colspan="props.line.colspan || 1"
|
||||
tabindex="-1"
|
||||
t-ref="lineNameCell"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<t t-if="props.line.unfoldable">
|
||||
<!-- Foldable -->
|
||||
<button data-id="btn_foldable" class="btn btn_foldable" tabindex="-1">
|
||||
<i t-att-class="props.line.unfolded ? 'fa fa-caret-down' : 'fa fa-caret-right'"/>
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- This keeps the same indentation for when we don't have a btn_foldable button -->
|
||||
<button class="btn btn_foldable_empty" tabindex="-1"/>
|
||||
</t>
|
||||
|
||||
<t t-if="props.line.action_id">
|
||||
<!-- Actions -->
|
||||
<a class="clickable" t-on-click="() => this.triggerAction()" tabindex="-1">
|
||||
<t t-out="props.line.name"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-elif="controller.isLoadMoreLine(props.lineIndex)">
|
||||
<!-- Load more -->
|
||||
<a class="clickable" t-on-click="() => this.loadMore()" tabindex="-1">
|
||||
<t t-out="props.line.name"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div data-id="content" class="content">
|
||||
<t t-component="env.component('AccountReportEllipsis')" t-props="{
|
||||
name: props.line.name?.toString(),
|
||||
type: 'string',
|
||||
maxCharacters: 80,
|
||||
}"/>
|
||||
|
||||
<t t-if="props.line.visible_annotations?.length">
|
||||
<button
|
||||
tabindex="-1"
|
||||
class="btn btn_annotation"
|
||||
t-on-click="toggleAnnotationPopover"
|
||||
>
|
||||
<i class="fa fa-commenting"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
<t t-if="hasCaretOptions">
|
||||
<!-- Caret options -->
|
||||
<Dropdown position="'right-start'">
|
||||
<button class="btn btn_dropdown" tabindex="-1">
|
||||
<i class="fa fa-ellipsis-v"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div data-id="caret_options">
|
||||
<t t-foreach="caretOptions" t-as="caretOption" t-key="caretOption_index">
|
||||
<DropdownItem onSelected="() => this.caretAction(caretOption)">
|
||||
<t t-out="caretOption.name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-if="!props.line.visible_annotations?.length">
|
||||
<div class="o-dropdown-item dropdown-item o-navigable"
|
||||
t-on-click="(ev) => this.addAnnotation(ev, props.line.id)"
|
||||
>
|
||||
Annotate
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
<t data-id="line_buttons" t-if="!props.line['hide_line_buttons']"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,138 @@
|
||||
// Fusion Accounting - Account Report Annotations Popover
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component, useState, useRef, onWillDestroy } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||
import { AnnotationPopoverLine } from "@fusion_accounting/components/account_report/line_name/popover_line/annotation_popover_line";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class AccountReportAnnotationsPopover extends Component {
|
||||
static template = "fusion_accounting.AccountReportAnnotationsPopover";
|
||||
static props = {
|
||||
controller: Object,
|
||||
lineName: Object,
|
||||
lineID: String,
|
||||
close: { type: Function, optional: true },
|
||||
isAddingAnnotation: { type: Boolean, optional: true },
|
||||
};
|
||||
static components = {
|
||||
DateTimeInput,
|
||||
AnnotationPopoverLine,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notificationService = useService("notification");
|
||||
|
||||
this.newAnnotation = useState({
|
||||
value: this.props.isAddingAnnotation ? this._getNewAnnotation() : {},
|
||||
});
|
||||
|
||||
this.annotations = useState(this.props.controller.visibleAnnotations[this.props.lineID]);
|
||||
|
||||
this.popoverTable = useRef("popoverTable");
|
||||
this.currentPromise = null;
|
||||
|
||||
onWillDestroy(async () => {
|
||||
if (this.currentPromise) {
|
||||
await this.currentPromise;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isAddingAnnotation() {
|
||||
return Object.keys(this.newAnnotation.value).length !== 0;
|
||||
}
|
||||
|
||||
async refreshAnnotations() {
|
||||
this.currentPromise = null;
|
||||
await this.props.controller.refreshAnnotations();
|
||||
this.annotations = this.props.controller.visibleAnnotations[this.props.lineID];
|
||||
if (this.isAddingAnnotation) {
|
||||
this.cleanNewAnnotation();
|
||||
}
|
||||
}
|
||||
|
||||
_getNewAnnotation() {
|
||||
const date =
|
||||
this.props.controller.options.date.filter === "today"
|
||||
? new Date().toISOString().split("T")[0]
|
||||
: this.props.controller.options.date.date_to;
|
||||
return {
|
||||
date: DateTime.fromISO(date),
|
||||
text: "",
|
||||
lineID: this.props.lineID,
|
||||
};
|
||||
}
|
||||
|
||||
cleanNewAnnotation() {
|
||||
this.newAnnotation.value = {};
|
||||
}
|
||||
|
||||
addAnnotation() {
|
||||
this.newAnnotation.value = this._getNewAnnotation();
|
||||
}
|
||||
|
||||
formatAnnotation(annotation) {
|
||||
return {
|
||||
id: annotation.id,
|
||||
date: annotation.date ? DateTime.fromISO(annotation.date) : null,
|
||||
text: annotation.text,
|
||||
lineID: annotation.line_id,
|
||||
};
|
||||
}
|
||||
|
||||
async saveNewAnnotation(newAnnotation) {
|
||||
if (newAnnotation.text) {
|
||||
this.currentPromise = this.env.services.orm.call(
|
||||
"account.report.annotation",
|
||||
"create",
|
||||
[
|
||||
{
|
||||
report_id: this.props.controller.options.report_id,
|
||||
line_id: newAnnotation.lineID,
|
||||
text: newAnnotation.text,
|
||||
date: newAnnotation.date ? newAnnotation.date.toFormat("yyyy-LL-dd") : null,
|
||||
fiscal_position_id: this.props.controller.options.fiscal_position,
|
||||
},
|
||||
],
|
||||
{
|
||||
context: this.props.context,
|
||||
}
|
||||
);
|
||||
// We're using a .then() here to make sure that even if the component is destroyed
|
||||
// we'll call the function to finalize the logic.
|
||||
this.currentPromise.then(async () => {
|
||||
await this.refreshAnnotations();
|
||||
if (this.popoverTable.el) {
|
||||
this.popoverTable.el.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAnnotation(annotationId) {
|
||||
this.currentPromise = this.env.services.orm.call(
|
||||
"account.report.annotation",
|
||||
"unlink",
|
||||
[annotationId],
|
||||
{ context: this.props.controller.context }
|
||||
);
|
||||
await this.currentPromise;
|
||||
await this.refreshAnnotations();
|
||||
}
|
||||
|
||||
async editAnnotation(editedAnnotation, existingAnnotation) {
|
||||
this.currentPromise = this.env.services.orm.call(
|
||||
"account.report.annotation",
|
||||
"write",
|
||||
[[existingAnnotation.id], { text: editedAnnotation.text, date: editedAnnotation.date }],
|
||||
{
|
||||
context: this.props.controller.context,
|
||||
}
|
||||
);
|
||||
await this.currentPromise;
|
||||
await this.refreshAnnotations();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportAnnotationsPopover">
|
||||
<div class="d-flex flex-column flex-start gap-2">
|
||||
<table class="annotation_popover" t-ref="popoverTable">
|
||||
<tr class="annotation_popover_line">
|
||||
<th>Date</th>
|
||||
<th>Annotation</th>
|
||||
<th/>
|
||||
</tr>
|
||||
|
||||
<t t-foreach="annotations" t-as="annotation" t-key="annotation.id">
|
||||
<AnnotationPopoverLine
|
||||
annotation="this.formatAnnotation(annotation)"
|
||||
onDelete="(annotationId) => this.deleteAnnotation(annotationId)"
|
||||
onEdit="(editedAnnotation) => this.editAnnotation(editedAnnotation, annotation)"
|
||||
/>
|
||||
</t>
|
||||
<AnnotationPopoverLine
|
||||
t-if="isAddingAnnotation"
|
||||
annotation="newAnnotation.value"
|
||||
onDelete="() => this.cleanNewAnnotation()"
|
||||
onEdit="(annotationToCreate) => this.saveNewAnnotation(annotationToCreate)"
|
||||
/>
|
||||
<tr class="annotation_popover_line">
|
||||
<td colspan="4">
|
||||
<a class="oe_link" href="#" t-on-click.prevent="addAnnotation" data-hotkey="c">
|
||||
Add a line
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Fusion Accounting - Account Report Annotation Popover Line
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component, useState, useRef, useEffect } from "@odoo/owl";
|
||||
import { useAutofocus, useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class AnnotationPopoverLine extends Component {
|
||||
static template = "account_report.AnnotationPopoverLine";
|
||||
static props = {
|
||||
annotation: {
|
||||
type: Object,
|
||||
shape: {
|
||||
date: { type: [DateTime, { value: false }, { value: null }], optional: true },
|
||||
text: String,
|
||||
lineID: String,
|
||||
id: { type: Number, optional: true },
|
||||
},
|
||||
},
|
||||
onEdit: Function,
|
||||
onDelete: Function,
|
||||
};
|
||||
static components = {
|
||||
DateTimeInput,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.applyAutoresizeToAll(".annotation_popover_autoresize_textarea");
|
||||
this.annotation = useState(this.props.annotation);
|
||||
this.notificationService = useService("notification");
|
||||
if (this.annotation.text.length) {
|
||||
this.textArea = useRef("annotationText");
|
||||
} else {
|
||||
this.textArea = useAutofocus({ refName: "annotationText" });
|
||||
}
|
||||
}
|
||||
|
||||
applyAutoresizeToAll(selector) {
|
||||
useEffect(() => {
|
||||
const resizeTextArea = (textarea) => {
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
textarea.parentElement.style.height = `${textarea.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const textareas = document.querySelectorAll(selector);
|
||||
for (const textarea of textareas) {
|
||||
Object.assign(textarea.style, {
|
||||
width: "auto",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
});
|
||||
resizeTextArea(textarea);
|
||||
textarea.addEventListener("input", () => {
|
||||
resizeTextArea(textarea);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
annotationEditDate(date) {
|
||||
this.annotation.date = date;
|
||||
this.props.onEdit(this.annotation);
|
||||
}
|
||||
|
||||
annotationEditText() {
|
||||
if (this.textArea.el.value) {
|
||||
if (this.textArea.el.value !== this.annotation.text) {
|
||||
this.annotation.text = this.textArea.el.value;
|
||||
this.props.onEdit(this.annotation);
|
||||
}
|
||||
} else {
|
||||
this.notificationService.add(_t("The annotation shouldn't have an empty value."));
|
||||
}
|
||||
}
|
||||
|
||||
deleteAnnotation() {
|
||||
this.props.onDelete(this.annotation.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="account_report.AnnotationPopoverLine">
|
||||
<tr class="annotation_popover_line">
|
||||
<td class="w-25">
|
||||
<DateTimeInput
|
||||
type="'date'"
|
||||
value="annotation.date"
|
||||
onChange="(date) => this.annotationEditDate(date)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<textarea
|
||||
class="annotation_popover_editable_cell annotation_popover_autoresize_textarea w-100"
|
||||
t-att-value="annotation.text"
|
||||
t-on-change="annotationEditText"
|
||||
t-ref="annotationText"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn_annotation_delete"
|
||||
t-on-click="deleteAnnotation"
|
||||
>
|
||||
<i class="fa fa-trash-o"/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,52 @@
|
||||
// Fusion Accounting - Account Report Search Bar
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component, useRef, useState, onMounted } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AccountReportSearchBar - Text search input for filtering report lines
|
||||
* by name or content within the currently displayed report.
|
||||
*/
|
||||
export class AccountReportSearchBar extends Component {
|
||||
static template = "fusion_accounting.AccountReportSearchBar";
|
||||
static props = {
|
||||
initialQuery: { type: String, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.searchText = useRef("search_bar_input");
|
||||
this.controller = useState(this.env.controller);
|
||||
|
||||
onMounted(() => {
|
||||
if (this.props.initialQuery) {
|
||||
this.searchText.el.value = this.props.initialQuery;
|
||||
this.search();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Search
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
search() {
|
||||
const inputText = this.searchText.el.value.trim();
|
||||
const query = inputText.toLowerCase();
|
||||
const linesIDsMatched = [];
|
||||
|
||||
if (query.length) {
|
||||
for (const line of this.controller.lines) {
|
||||
const lineName = line.name.trim().toLowerCase();
|
||||
const match = (lineName.indexOf(query) !== -1);
|
||||
|
||||
if (match) {
|
||||
linesIDsMatched.push(line.id);
|
||||
}
|
||||
}
|
||||
this.controller.lines_searched = linesIDsMatched;
|
||||
this.controller.updateOption("filter_search_bar", inputText);
|
||||
} else {
|
||||
delete this.controller.lines_searched;
|
||||
this.controller.deleteOption("filter_search_bar");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportSearchBar">
|
||||
<div class="o_searchview form-control d-print-contents d-flex align-items-center py-1 w-auto" role="search" aria-autocomplete="list">
|
||||
<i class="o_searchview_icon d-print-none oi oi-search me-2"
|
||||
role="img"
|
||||
aria-label="Search..."
|
||||
title="Search..."
|
||||
/>
|
||||
<div class="o_searchview_input_container d-flex flex-grow-1 flex-wrap gap-1">
|
||||
<input type="text"
|
||||
class="o_searchview_input o_input d-print-none flex-grow-1 w-auto border-0"
|
||||
accesskey="Q"
|
||||
placeholder="Search..."
|
||||
role="searchbox"
|
||||
t-ref="search_bar_input"
|
||||
t-on-input="() => this.search()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.common_warning_tax_unit">
|
||||
This company is part of a tax unit. You're currently not viewing the whole unit.
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.common_warning_draft_in_period">
|
||||
There are
|
||||
<a type="button" t-on-click="(ev) => controller.reportAction(ev, 'open_unposted_moves', {})">unposted Journal Entries</a>
|
||||
prior or included in this period.
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.common_possibly_unbalanced_because_cta">
|
||||
This report uses the CTA conversion method to consolidate multiple companies using different currencies,
|
||||
which can lead the report to be unbalanced.
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,4 @@
|
||||
.account_report.aged_partner_balance {
|
||||
.partner_trust { line-height: 20px }
|
||||
td[data-expression_label='currency'] > .wrapper { justify-content: center }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AccountReportAgingBasedOn">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-file me-1"/>Based on
|
||||
<t t-if="controller.options.aging_based_on === 'base_on_invoice_date'">
|
||||
Invoice Date
|
||||
</t>
|
||||
<t t-else="">
|
||||
Due Date
|
||||
</t>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.aging_based_on === 'base_on_maturity_date' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'aging_based_on', optionValue: 'base_on_maturity_date', reload: true})"
|
||||
>
|
||||
Due Date
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.aging_based_on === 'base_on_invoice_date' }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'aging_based_on', optionValue: 'base_on_invoice_date', reload: true})"
|
||||
>
|
||||
Invoice Date
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.AccountReportAgingInterval">
|
||||
<Dropdown
|
||||
menuClass="'account_report_filter intervals'"
|
||||
>
|
||||
<button class="btn btn-secondary">
|
||||
<i class="fa fa-calendar-o me-1"/>
|
||||
<t t-out="controller.options.aging_interval"/> Days
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div class="dropdown-item interval">
|
||||
<input
|
||||
min="1"
|
||||
type="number"
|
||||
t-att-value="controller.options.aging_interval"
|
||||
t-on-change="(ev) => this.setAgingInterval(ev)"
|
||||
/>
|
||||
<span class="mx-2">Days</span>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AgedPartnerBalanceFilterExtraOptions" t-inherit="fusion_accounting.AccountReportFilterExtraOptions">
|
||||
<xpath expr="//DropdownItem[contains(@class, 'filter_show_all_hook')]" position="after">
|
||||
<DropdownItem
|
||||
t-if="controller.options.multi_currency"
|
||||
class="{ 'selected': controller.options.show_currency }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'show_currency', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Show Currency
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
class="{ 'selected': controller.options.show_account }"
|
||||
onSelected="() => this.filterClicked({ optionKey: 'show_account', reload: true})"
|
||||
closingMode="'none'"
|
||||
>
|
||||
Show Account
|
||||
</DropdownItem>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,34 @@
|
||||
// Fusion Accounting - Aged Partner Balance Filters
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { WarningDialog } from "@web/core/errors/error_dialogs";
|
||||
import { AccountReport } from "@fusion_accounting/components/account_report/account_report";
|
||||
import { AccountReportFilters } from "@fusion_accounting/components/account_report/filters/filters";
|
||||
|
||||
/**
|
||||
* AgedPartnerBalanceFilters - Extended filter panel for the aged partner
|
||||
* balance report, adding aging interval configuration controls.
|
||||
*/
|
||||
export class AgedPartnerBalanceFilters extends AccountReportFilters {
|
||||
static template = "fusion_accounting.AgedPartnerBalanceFilters";
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Aging Interval
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
async setAgingInterval(ev) {
|
||||
const agingInterval = parseInt(ev.target.value);
|
||||
if (agingInterval < 1) {
|
||||
this.dialog.add(WarningDialog, {
|
||||
title: _t("Odoo Warning"),
|
||||
message: _t("Intervals cannot be smaller than 1"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.filterClicked({ optionKey:"aging_interval", optionValue: agingInterval, reload: true });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
AccountReport.registerCustomComponent(AgedPartnerBalanceFilters);
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AgedPartnerBalanceFilters" t-inherit="fusion_accounting.AccountReportFiltersCustomizable">
|
||||
<xpath expr="//div[@id='filter_extra_options']" position="before">
|
||||
<t t-if="'aging_based_on' in controller.options">
|
||||
<div>
|
||||
<t t-call="fusion_accounting.AccountReportAgingBasedOn"/>
|
||||
</div>
|
||||
</t>
|
||||
<t>
|
||||
<div id="filter_aging_interval">
|
||||
<t t-call="fusion_accounting.AccountReportAgingInterval"/>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@id='filter_extra_options']" position="replace">
|
||||
<t t-call="fusion_accounting.AgedPartnerBalanceFilterExtraOptions"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting.AgedPartnerBalanceLineName" t-inherit="fusion_accounting.AccountReportLineNameCustomizable">
|
||||
<xpath expr="//td[@data-id='line_name']" position="attributes">
|
||||
<t t-if="props.line.unfoldable">
|
||||
<attribute name="class" add="more" separator=" "/>
|
||||
</t>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//t[@data-id='line_buttons']" position="inside">
|
||||
<t t-if="props.line.unfoldable">
|
||||
<button
|
||||
class="btn btn_action"
|
||||
t-on-click="(ev) => controller.reportAction(ev, 'caret_option_open_record_form', { line_id: props.line.id })"
|
||||
>
|
||||
Partner
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn_action"
|
||||
t-on-click="(ev) => controller.reportAction(ev, 'open_partner_ledger', { line_id: props.line.id })"
|
||||
>
|
||||
Ledger
|
||||
</button>
|
||||
</t>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//button[@data-id='btn_foldable']" position="after">
|
||||
<t t-if="props.line.trust === 'good'">
|
||||
<i class="fa fa-circle text-success partner_trust" title="Partner is good"/>
|
||||
</t>
|
||||
<t t-elif="props.line.trust === 'bad'">
|
||||
<i class="fa fa-circle text-danger partner_trust" title="Partner is bad"/>
|
||||
</t>
|
||||
</xpath>
|
||||
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,55 @@
|
||||
// Fusion Accounting - Bank Reconciliation AML List View
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { EmbeddedListView } from "./embedded_list_view";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { useState, onWillUnmount } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* BankRecAmlsRenderer - Custom list renderer for displaying account
|
||||
* move lines (AMLs) within the bank reconciliation matching panel.
|
||||
* Preserves search state across re-renders and handles line selection.
|
||||
*/
|
||||
export class BankRecAmlsRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.globalState = useState(this.env.methods.getState());
|
||||
|
||||
onWillUnmount(this.saveSearchState);
|
||||
}
|
||||
|
||||
/** @override **/
|
||||
getRowClass(record) {
|
||||
const classes = super.getRowClass(record);
|
||||
const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId);
|
||||
if (amlId){
|
||||
return `${classes} o_rec_widget_list_selected_item table-info`;
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
/** @override **/
|
||||
async onCellClicked(record, column, ev) {
|
||||
const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId);
|
||||
if (amlId) {
|
||||
this.env.config.actionRemoveNewAml(record.resId);
|
||||
} else {
|
||||
this.env.config.actionAddNewAml(record.resId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Backup the search facets in order to restore them when the user comes back on this view. **/
|
||||
saveSearchState() {
|
||||
const initParams = this.globalState.bankRecEmbeddedViewsData.amls;
|
||||
const searchModel = this.env.searchModel;
|
||||
initParams.exportState = {searchModel: JSON.stringify(searchModel.exportState())};
|
||||
}
|
||||
}
|
||||
|
||||
export const BankRecAmls = {
|
||||
...EmbeddedListView,
|
||||
Renderer: BankRecAmlsRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("bank_rec_amls_list_view", BankRecAmls);
|
||||
@@ -0,0 +1,755 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<!-- Buttons -->
|
||||
<t t-name="fusion_accounting.BankRecRecordFormButtonsHeaderLeft">
|
||||
<div class="o_statusbar_buttons o_bank_rec_stats_buttons_aside_left py-1 ps-1">
|
||||
<t t-set="areAllLinesValid" t-value="this.checkBankRecLinesInvalidFields(data)"/>
|
||||
<button accesskey="v"
|
||||
class="btn btn-primary"
|
||||
t-if="data.state === 'valid' and areAllLinesValid"
|
||||
t-on-click="(ev) => this.actionValidate()">
|
||||
<span>Validate</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary text-muted"
|
||||
t-if="data.state === 'invalid' or (data.state === 'valid' and !areAllLinesValid)">
|
||||
<span>Validate</span>
|
||||
</button>
|
||||
<button accesskey="r"
|
||||
class="btn btn-secondary"
|
||||
t-if="data.state === 'reconciled'"
|
||||
t-on-click="(ev) => this.actionReset()">
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button accesskey="e"
|
||||
class="btn btn-secondary"
|
||||
t-if="data.st_line_checked and areAllLinesValid"
|
||||
t-on-click="(ev) => this.actionToCheck()">
|
||||
<span>To Check</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary text-muted"
|
||||
t-if="data.st_line_checked and !areAllLinesValid">
|
||||
<span>To Check</span>
|
||||
</button>
|
||||
<button accesskey="e"
|
||||
class="btn btn-secondary"
|
||||
t-if="!data.st_line_checked"
|
||||
t-on-click="(ev) => this.actionSetAsChecked()">
|
||||
<span>Set as Checked</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Reconciliation models -->
|
||||
<t t-name="fusion_accounting.BankRecRecordFormButtonsHeaderRight">
|
||||
<div t-if="hasGroupReadOnly" class="o_statusbar_buttons o_bank_rec_stats_buttons_aside_right">
|
||||
<div class="available_reco_model_ids">
|
||||
|
||||
<!-- Displayed reco models -->
|
||||
<t t-set="nb_rendered_buttons" t-value="0"/>
|
||||
|
||||
<div class="o_bank_rec_reco_models_widget_div">
|
||||
<t t-foreach="data.available_reco_model_ids.records"
|
||||
t-as="reco_model"
|
||||
t-key="reco_model.data.id">
|
||||
<t t-set="is_selected"
|
||||
t-value="reco_model.data.id === data.selected_reco_model_id[0]"/>
|
||||
|
||||
<t t-if="reco_model_index lt 5 or is_selected">
|
||||
<button
|
||||
t-att-accesskey="'SHIFT+'+(reco_model_index + 1)"
|
||||
t-on-click="() => this.actionSelectRecoModel(reco_model)"
|
||||
class="btn recon_model_button me-1 my-1"
|
||||
t-att-class="{'btn-secondary': !is_selected, 'btn-primary': is_selected}"
|
||||
t-out="reco_model.data.display_name"/>
|
||||
|
||||
<t t-set="nb_rendered_buttons" t-value="nb_rendered_buttons + 1"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Dropdown if more reco models -->
|
||||
<div t-if="data.available_reco_model_ids.records.length gt nb_rendered_buttons" class="bank_rec_reco_model_dropdown my-1">
|
||||
<Dropdown>
|
||||
<button class="btn btn-light">
|
||||
More
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="data.available_reco_model_ids.records" t-as="reco_model" t-key="reco_model.data.id">
|
||||
<t t-set="is_selected" t-value="reco_model.data.id === data.selected_reco_model_id.id"/>
|
||||
<DropdownItem t-if="reco_model_index gte 5 and !is_selected"
|
||||
onSelected="() => this.actionSelectRecoModel(reco_model)">
|
||||
<t t-out="reco_model.data.display_name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation of reco models -->
|
||||
<div class="bank_rec_reco_model_dropdown">
|
||||
<Dropdown position="'bottom-end'">
|
||||
<button class="btn btn-light">
|
||||
<i class="fa fa-cog"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem onSelected="() => this.actionCreateRecoModel()">
|
||||
Create model
|
||||
</DropdownItem>
|
||||
<DropdownItem onSelected="() => this.actionViewRecoModels()">
|
||||
View models
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- line_ids field -->
|
||||
<t t-name="fusion_accounting.BankRecRecordFormLineIds">
|
||||
<div name="line_ids" class="w-100">
|
||||
<div class="o_list_renderer table-responsive">
|
||||
<table class="o_list_table table table-sm position-relative mb-0 o_list_table_ungrouped table-striped o_bank_rec_lines_widget_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<t t-foreach="line_ids_columns" t-as="column" t-key="column[0]">
|
||||
<t t-if="['amount_currency', 'debit', 'credit', 'balance'].includes(column[0])">
|
||||
<th class="o_list_number_th text-end" t-esc="column[1]"/>
|
||||
</t>
|
||||
<t t-elif="column[0] === '__trash'">
|
||||
<th class="o_list_actions_header"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<th t-out="column[1]"/>
|
||||
</t>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="data.line_ids.records" t-as="line" t-key="line_index">
|
||||
<t t-set="invalidFields" t-value="this.getBankRecLineInvalidFields(line)"/>
|
||||
|
||||
<tr class="o_data_row o_selected_row o_list_no_open o_bank_rec_expanded_line"
|
||||
t-att-class="{
|
||||
'o_bank_rec_liquidity_line': line.data.flag === 'liquidity',
|
||||
'o_bank_rec_auto_balance_line': line.data.flag === 'auto_balance',
|
||||
'o_bank_rec_selected_line': line.data.index === data.form_index and this.state.bankRecNotebookPage === 'manual_operations_tab',
|
||||
'table-info': line.data.index === data.form_index and this.state.bankRecNotebookPage === 'manual_operations_tab',
|
||||
}"
|
||||
t-on-click="(ev) => this.handleLineClicked(ev, line)">
|
||||
|
||||
<t t-foreach="line_ids_columns" t-as="column" t-key="column[0]">
|
||||
<t t-if="column[0] === 'account'">
|
||||
<td class="o_data_cell o_field_cell o_list_many2one"
|
||||
t-att-class="{'o_invalid_cell': invalidFields.includes('account_id')}"
|
||||
field="account_id"
|
||||
t-att-title="line.data.account_id[1]"
|
||||
t-out="line.data.account_id[1]"/>
|
||||
</t>
|
||||
<t t-if="column[0] === 'partner'">
|
||||
<t t-if="line.data.flag === 'liquidity' and !line.data.partner_id">
|
||||
<td class="o_data_cell o_field_cell o_list_many2one text-muted"
|
||||
field="partner_id"
|
||||
t-att-title="data.partner_name or ''"
|
||||
t-out="data.partner_name or ''"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td class="o_data_cell o_field_cell o_list_many2one"
|
||||
field="partner_id"
|
||||
t-att-title="line.data.partner_id[1]"
|
||||
t-out="line.data.partner_id[1]"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="column[0] === 'date'">
|
||||
<t t-if="['manual', 'early_payment', 'auto_balance', 'tax_line'].includes(line.data.flag)">
|
||||
<td class="o_data_cell o_field_cell" field="date">
|
||||
<span field="date" class="badge text-bg-secondary">New</span>
|
||||
</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td class="o_data_cell o_field_cell"
|
||||
t-att-class="{'o_invalid_cell': invalidFields.includes('date')}"
|
||||
field="date"
|
||||
t-out="formatDateField(line.data.date)"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="column[0] === 'analytic_distribution'">
|
||||
<td class="o_data_cell o_field_cell o_field_widget o_field_analytic_distribution" field="analytic_distribution">
|
||||
<div class="o_field_tags d-inline-flex flex-wrap mw-100">
|
||||
<AnalyticDistribution
|
||||
name="'analytic_distribution'"
|
||||
record="line"
|
||||
readonly="true"
|
||||
t-key="getKey(line.data)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</t>
|
||||
<t t-if="column[0] === 'taxes'" name="col_taxes">
|
||||
<td class="o_data_cell o_field_cell o_field_widget o_field_many2many_tags"
|
||||
field="tax_ids">
|
||||
<div class="o_field_tags d-inline-flex flex-wrap mw-100">
|
||||
<TagsList tags="line.data.tax_ids.records.map((tax) => ({text: tax.data.display_name, id: tax.resId}))"/>
|
||||
</div>
|
||||
</td>
|
||||
</t>
|
||||
<t t-if="column[0] === 'amount_currency'">
|
||||
<t t-if="line.data.flag === 'exchange_diff'">
|
||||
<td/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td class="o_data_cell o_field_cell o_list_number"
|
||||
field="amount_currency"
|
||||
style="justify-content: right;"
|
||||
t-out="isMonetaryZero(line.data.amount_currency, line.data.currency_id[0]) ? '' : formatMonetaryField(line.data.amount_currency, line.data.currency_id[0])"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="column[0] === 'currency'">
|
||||
<t t-if="line.data.flag === 'exchange_diff'">
|
||||
<td/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td class="o_data_cell o_field_cell o_list_many2one"
|
||||
field="currency_id"
|
||||
t-att-title="line.data.currency_id[1]"
|
||||
t-out="line.data.currency_id[1]"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="column[0] === 'debit'">
|
||||
<td class="o_data_cell o_field_cell o_list_number"
|
||||
field="debit"
|
||||
t-out="isMonetaryZero(line.data.debit, line.data.company_currency_id[0]) ? '' : formatMonetaryField(line.data.debit, line.data.company_currency_id[0])"/>
|
||||
</t>
|
||||
<t t-if="column[0] === 'credit'">
|
||||
<td class="o_data_cell o_field_cell o_list_number"
|
||||
field="credit"
|
||||
t-out="isMonetaryZero(line.data.credit, line.data.company_currency_id[0]) ? '' : formatMonetaryField(line.data.credit, line.data.company_currency_id[0])"/>
|
||||
</t>
|
||||
<t t-if="column[0] === 'balance'">
|
||||
<td class="o_data_cell o_field_cell o_list_number"
|
||||
field="balance"
|
||||
t-out="isMonetaryZero(line.data.balance, line.data.company_currency_id[0]) ? '' : formatMonetaryField(line.data.balance, line.data.company_currency_id[0])"/>
|
||||
</t>
|
||||
<t t-if="column[0] === '__trash'">
|
||||
<td class="o_list_record_remove">
|
||||
<button t-if="['valid', 'invalid'].includes(data.state) and !['liquidity', 'auto_balance', 'tax_line'].includes(line.data.flag)"
|
||||
t-on-click.prevent.stop="(ev) => this.actionRemoveLine(line)"
|
||||
class="btn fa fa-trash-o"/>
|
||||
</td>
|
||||
</t>
|
||||
</t>
|
||||
</tr>
|
||||
<!-- Ensure the same color band will be applied -->
|
||||
<tr/>
|
||||
<tr class="o_data_row o_selected_row o_list_no_open o_bank_rec_second_line"
|
||||
t-att-class="{
|
||||
'o_bank_rec_liquidity_line': line.data.flag === 'liquidity',
|
||||
'o_bank_rec_auto_balance_line': line.data.flag === 'auto_balance',
|
||||
'o_bank_rec_selected_line': line.data.index === data.form_index and this.state.bankRecNotebookPage === 'manual_operations_tab',
|
||||
'table-info': line.data.index === data.form_index and this.state.bankRecNotebookPage === 'manual_operations_tab',
|
||||
}"
|
||||
t-on-click="(ev) => this.handleLineClicked(ev, line)">
|
||||
<td t-att-colspan="line_ids_columns.length - (display_currency_columns ? 5 : 3) + (hasGroupReadOnly ? 0 : 1)"
|
||||
class="o_data_cell o_field_cell o_list_char text-wrap fw-normal"
|
||||
field="name">
|
||||
<t t-if="line.data.source_aml_move_id[0] and (['new_aml', 'aml'].includes(line.data.flag) or (line.data.flag == 'liquidity' and data.state == 'reconciled' and hasGroupReadOnly))">
|
||||
<span class="o_form_uri fst-italic"
|
||||
t-out="line.data.source_aml_move_id[1]"
|
||||
t-on-click="() => this.actionRedirectToSourceMove(line)"/>
|
||||
<span class="me-1 fst-italic" t-if="line.data.name">:</span>
|
||||
</t>
|
||||
<span t-if="line.data.bank_account"
|
||||
class="text-muted fst-italic me-1"
|
||||
t-out="line.data.bank_account"/>
|
||||
<span class="text-muted fst-italic"
|
||||
t-out="line.data.name"/>
|
||||
</td>
|
||||
<td t-if="display_currency_columns"
|
||||
class="o_data_cell o_field_cell o_list_number"
|
||||
field="amount_currency">
|
||||
<span t-if="line.data.display_stroked_amount_currency"
|
||||
class="text-muted text-decoration-line-through"
|
||||
field="amount_currency"
|
||||
t-out="formatMonetaryField(line.data.source_amount_currency, line.data.currency_id[0])"/>
|
||||
</td>
|
||||
<td t-if="display_currency_columns" field="currency_id"/>
|
||||
<td t-if="hasGroupReadOnly"
|
||||
class="o_data_cell o_field_cell o_list_number text-end"
|
||||
field="debit">
|
||||
<span t-if="line.data.display_stroked_balance and !isMonetaryZero(line.data.debit, line.data.company_currency_id[0])"
|
||||
class="text-muted text-decoration-line-through"
|
||||
field="debit"
|
||||
t-out="formatMonetaryField(line.data.source_debit, line.data.company_currency_id[0])"/>
|
||||
</td>
|
||||
<td t-if="hasGroupReadOnly"
|
||||
class="o_data_cell o_field_cell o_list_number text-end"
|
||||
field="credit">
|
||||
<span t-if="line.data.display_stroked_balance and !isMonetaryZero(line.data.credit, line.data.company_currency_id[0])"
|
||||
class="text-muted text-decoration-line-through"
|
||||
field="credit"
|
||||
t-out="formatMonetaryField(line.data.source_credit, line.data.company_currency_id[0])"/>
|
||||
</td>
|
||||
<td t-if="!hasGroupReadOnly"
|
||||
class="o_data_cell o_field_cell o_list_number text-end"
|
||||
field="balance">
|
||||
<span t-if="line.data.display_stroked_balance and (!isMonetaryZero(line.data.debit, line.data.company_currency_id[0]) or !isMonetaryZero(line.data.credit, line.data.company_currency_id[0]))"
|
||||
class="text-muted text-decoration-line-through"
|
||||
field="balance"
|
||||
t-out="formatMonetaryField(line.data.source_debit or line.data.source_credit, line.data.company_currency_id[0])"/>
|
||||
</td>
|
||||
<td class="empty_trash_cell"/>
|
||||
</tr>
|
||||
</t>
|
||||
|
||||
<t t-set="nb_extra_lines" t-value="5 - data.line_ids.records.length"/>
|
||||
<tr t-foreach="[...Array(Math.max(nb_extra_lines, 0)).keys()]" t-as="i" t-key="i">
|
||||
<td t-att-colspan="line_ids_columns.length">‍</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Notebook - amls_tab -->
|
||||
<t t-name="fusion_accounting.BankRecRecordNotebookAmls">
|
||||
<div class="bank_rec_widget_form_amls_list_anchor" t-if="this.state.bankRecEmbeddedViewsData">
|
||||
<BankRecViewEmbedder viewProps="this.notebookAmlsListViewProps()" t-key="data.st_line_id[0]"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Notebook - manual_operations_tab -->
|
||||
<t t-name="fusion_accounting.BankRecRecordNotebookManualOperations">
|
||||
<t t-set="line" t-value="this.getBankRecRecordLineInEdit()"/>
|
||||
<t t-set="data" t-value="this.state.bankRecRecordData"/>
|
||||
<div t-if="line" class="bank_rec_widget_manual_operations_anchor">
|
||||
<t t-set="invalidFields" t-value="this.getBankRecLineInvalidFields(line)"/>
|
||||
<t t-set="hasForcedNegativeValue"
|
||||
t-value="['new_aml', 'exchange_diff'].includes(line.data.flag) and (line.data.source_balance lt 0.0 || line.data.source_amount_currency lt 0.0)"/>
|
||||
<t t-set="reconciled"
|
||||
t-value="data.state === 'reconciled'"/>
|
||||
|
||||
<div class="o_group row align-items-start">
|
||||
<div class=" o_inner_group grid col-lg-6">
|
||||
<!-- partner_id -->
|
||||
<t t-set="readonly"
|
||||
t-value="reconciled || ['tax_line', 'new_aml', 'exchange_diff'].includes(line.data.flag)"/>
|
||||
<div name="partner_id"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': readonly}">Partner</label>
|
||||
</div>
|
||||
<div class="o_field_widget o_field_many2one">
|
||||
<Many2OneField
|
||||
name="'partner_id'"
|
||||
record="line"
|
||||
readonly="readonly"
|
||||
canQuickCreate="false"
|
||||
domain="[['company_id', 'in', [false, data.company_id[0]]], '|', ['parent_id', '=', false], ['is_company', '=', true]]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- partner extra values -->
|
||||
<div t-if="['auto_balance', 'manual'].includes(line.data.flag)
|
||||
and line.data.partner_receivable_account_id
|
||||
and line.data.partner_payable_account_id"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell flex-grow-1 flex-sm-grow-0 o_wrap_label w-100 text-break text-900"/>
|
||||
<div class="o_cell flex-grow-1 flex-sm-grow-0" style="width: 100%;">
|
||||
<div class="d-flex" style="align-items: center; font-size: 0.9rem">
|
||||
<button class="btn btn-link"
|
||||
style="white-space: nowrap"
|
||||
t-on-click="() => this.actionSetPartnerReceivableAccount()">Receivable:</button>
|
||||
<div class="o_field_widget o_readonly_modifier o_field_monetary ml4 mb0">
|
||||
<span t-out="formatMonetaryField(line.data.partner_receivable_amount, line.data.partner_currency_id[0])"/>
|
||||
</div>
|
||||
<span class="ml4 mr4">-</span>
|
||||
<button class="btn btn-link"
|
||||
style="white-space: nowrap"
|
||||
t-on-click="() => this.actionSetPartnerPayableAccount()">Payable:</button>
|
||||
<div class="o_field_widget o_readonly_modifier o_field_monetary ml4 mb0">
|
||||
<span t-out="formatMonetaryField(line.data.partner_payable_amount, line.data.partner_currency_id[0])"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- bank_account -->
|
||||
<div name="bank_account"
|
||||
t-if="line.data.flag === 'liquidity' and line.data.bank_account"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label o_form_label_readonly">Bank Account</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_many2one">
|
||||
<t t-out="line.data.bank_account"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- account_id -->
|
||||
<t t-set="readonly"
|
||||
t-value="reconciled || ['liquidity', 'new_aml', 'exchange_diff'].includes(line.data.flag)"/>
|
||||
<div name="account_id"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_field_invalid': invalidFields.includes('account_id'), 'o_form_label_readonly': readonly}">Account</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_many2one"
|
||||
t-att-class="{'o_field_invalid': invalidFields.includes('account_id')}">
|
||||
<Many2OneField
|
||||
name="'account_id'"
|
||||
record="line"
|
||||
readonly="readonly"
|
||||
canOpen="false"
|
||||
canQuickCreate="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tax_ids -->
|
||||
<div name="tax_ids"
|
||||
t-if="['manual', 'auto_balance'].includes(line.data.flag)"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': reconciled}">Taxes</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_many2many_tags">
|
||||
<Many2ManyTagsField
|
||||
name="'tax_ids'"
|
||||
record="line"
|
||||
readonly="reconciled"
|
||||
canCreate="false"
|
||||
canQuickCreate="false"
|
||||
canCreateEdit="false"
|
||||
context="{'append_type_to_tax_name': true}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- analytic_distribution -->
|
||||
<div name="analytic_distribution"
|
||||
t-if="hasGroupAnalyticAccounting"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': reconciled}">Analytic</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_analytic_distribution">
|
||||
<!-- t-key is needed to force component rerender and avoid synchronization issue on record change -->
|
||||
<AnalyticDistribution
|
||||
name="'analytic_distribution'"
|
||||
record="line"
|
||||
readonly="reconciled"
|
||||
account_field="'account_id'"
|
||||
business_domain="'general'"
|
||||
allow_save="true"
|
||||
t-key="line.data.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- date -->
|
||||
<div name="date"
|
||||
t-if="line.data.flag === 'liquidity'"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_field_invalid': invalidFields.includes('date'), 'o_form_label_readonly': reconciled}">Date</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_date"
|
||||
t-att-class="{'o_field_invalid': invalidFields.includes('date')}">
|
||||
<DateTimeField
|
||||
name="'date'"
|
||||
record="line"
|
||||
readonly="reconciled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- notes/narration -->
|
||||
<div name="narration"
|
||||
t-if="line.data.flag === 'liquidity' and (!reconciled || (reconciled and line.data.narration))"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': reconciled}">Notes</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_char">
|
||||
<HtmlField
|
||||
name="'narration'"
|
||||
record="line"
|
||||
readonly="reconciled"
|
||||
wysiwygOptions="{}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_inner_group grid col-lg-6">
|
||||
<t t-set="readonly"
|
||||
t-value="reconciled || line.data.flag === 'exchange_diff'"/>
|
||||
<!-- name -->
|
||||
<div name="name"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': readonly}">Label</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_char">
|
||||
<CharField
|
||||
name="'name'"
|
||||
record="line"
|
||||
readonly="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reference -->
|
||||
<div name="reference"
|
||||
t-if="line.data.flag === 'liquidity' and (!reconciled || (reconciled and line.data.ref))"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': readonly}">Reference</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="o_field_widget o_field_char">
|
||||
<CharField
|
||||
name="'ref'"
|
||||
record="line"
|
||||
readonly="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- amount_currency (in journal currency) -->
|
||||
<t t-set="amountFieldReadonly"
|
||||
t-value="reconciled"/>
|
||||
<t t-set="currencyFieldReadonly"
|
||||
t-value="reconciled || ['liquidity', 'tax_line', 'new_aml'].includes(line.data.flag)"/>
|
||||
<div name="amount_currency"
|
||||
t-if="line.data.flag !== 'exchange_diff'"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': amountFieldReadonly}">Amount</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="d-flex">
|
||||
<!-- amount -->
|
||||
<div class="o_field_widget o_field_monetary">
|
||||
<BankRecMonetaryField
|
||||
name="'amount_currency'"
|
||||
record="line"
|
||||
readonly="amountFieldReadonly"
|
||||
hasForcedNegativeValue="hasForcedNegativeValue"
|
||||
/>
|
||||
</div>
|
||||
<!-- in -->
|
||||
<span t-if="!currencyFieldReadonly" class="ml4 mr4">in</span>
|
||||
<!-- currency -->
|
||||
<div class="o_field_widget o_field_many2one">
|
||||
<Many2OneField
|
||||
t-if="!currencyFieldReadonly"
|
||||
name="'currency_id'"
|
||||
record="line"
|
||||
readonly="currencyFieldReadonly"
|
||||
canOpen="false"
|
||||
canQuickCreate="false"
|
||||
canCreateEdit="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- amount_transaction_currency (in transaction currency) -->
|
||||
<t t-set="currencyFieldReadonly"
|
||||
t-value="reconciled || ['tax_line', 'new_aml'].includes(line.data.flag)"/>
|
||||
<div name="amount_transaction_currency"
|
||||
t-if="data.is_multi_currency and line.data.flag === 'liquidity' and (!reconciled || reconciled and line.data.transaction_currency_id[0])"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': amountFieldReadonly}">Amount (Foreign Currency)</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="d-flex">
|
||||
<!-- amount -->
|
||||
<div class="o_field_widget o_field_monetary">
|
||||
<BankRecMonetaryField
|
||||
t-if="line.data.transaction_currency_id"
|
||||
name="'amount_transaction_currency'"
|
||||
record="line"
|
||||
readonly="amountFieldReadonly"
|
||||
hasForcedNegativeValue="hasForcedNegativeValue"
|
||||
/>
|
||||
</div>
|
||||
<!-- in -->
|
||||
<span class="ml4 mr4">in</span>
|
||||
<!-- currency -->
|
||||
<div class="o_field_widget o_field_many2one">
|
||||
<Many2OneField
|
||||
name="'transaction_currency_id'"
|
||||
record="line"
|
||||
readonly="currencyFieldReadonly"
|
||||
canOpen="false"
|
||||
canQuickCreate="false"
|
||||
canCreateEdit="false"
|
||||
domain="[['id', '!=', line.data.currency_id[0]]]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- balance (in company currency) -->
|
||||
<t t-set="amountFieldReadonly"
|
||||
t-value="reconciled || (line.data.flag == 'liquidity' and line.data.company_currency_id[0] !== line.data.currency_id[0])"/>
|
||||
<div name="balance"
|
||||
t-if="line.data.company_currency_id[0] !== line.data.currency_id[0] and line.data.company_currency_id[0] !== line.data.transaction_currency_id[0]"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900">
|
||||
<label class="o_form_label"
|
||||
t-att-class="{'o_form_label_readonly': amountFieldReadonly}">Amount (Company Currency)</label>
|
||||
</div>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div class="d-flex">
|
||||
<!-- amount -->
|
||||
<div class="o_field_widget o_field_monetary mr4">
|
||||
<BankRecMonetaryField
|
||||
name="'balance'"
|
||||
record="line"
|
||||
readonly="amountFieldReadonly"
|
||||
hasForcedNegativeValue="hasForcedNegativeValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- suggestion -->
|
||||
<div name="suggestion"
|
||||
t-if="line.data.suggestion_html"
|
||||
class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<div class="o_cell o_wrap_label flex-grow-1 flex-sm-grow-0 w-100 text-break text-900"/>
|
||||
<div class="o_cell o_wrap_input flex-grow-1 flex-sm-grow-0"
|
||||
style="width: 100%; margin-bottom: 5px">
|
||||
<div t-on-click="handleSuggestionHtmlClicked">
|
||||
<t t-out="line.data.suggestion_html"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Notebook - discuss -->
|
||||
<t t-name="fusion_accounting.BankRecRecordNotebookChatter">
|
||||
<div class="bank_rec_widget_form_discuss_anchor">
|
||||
<Chatter threadModel="'account.move'" threadId="data.move_id[0]"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Notebook - transaction_details_tab -->
|
||||
<t t-name="fusion_accounting.BankRecRecordNotebookTransactionDetails">
|
||||
<div class="bank_rec_widget_form_transaction_details_anchor o_group row align-items-start">
|
||||
<div class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
|
||||
<span t-out="data.st_line_transaction_details"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Main template -->
|
||||
<t t-name="fusion_accounting.BankRecRecordForm">
|
||||
<div class="o_xxl_form_view h-100 o_view_controller o_form_view">
|
||||
<t t-set="record" t-value="this.bankRecModel.root"/>
|
||||
<t t-set="data" t-value="state.bankRecRecordData"/>
|
||||
<div class="o_form_view_container" t-ref="formContainer">
|
||||
<div class="o_content">
|
||||
<div class="o_form_nosheet o_form_editable d-block">
|
||||
<!-- Header -->
|
||||
<div class="o_bank_rec_stats_buttons">
|
||||
<t t-call="fusion_accounting.BankRecRecordFormButtonsHeaderLeft"/>
|
||||
<t t-call="fusion_accounting.BankRecRecordFormButtonsHeaderRight"/>
|
||||
</div>
|
||||
|
||||
<!-- line_ids -->
|
||||
<t t-call="fusion_accounting.BankRecRecordFormLineIds">
|
||||
<t t-set="line_ids_columns" t-value="this.getOne2ManyColumns()"/>
|
||||
<t t-set="display_currency_columns" t-value="!!line_ids_columns.find((c) => c[0] == 'currency')"/>
|
||||
</t>
|
||||
|
||||
<!-- Notebook -->
|
||||
<Notebook t-key="state.bankRecStLineId" defaultPage="state.bankRecNotebookPage || ''" onPageUpdate.bind="onPageUpdate" >
|
||||
<t t-set-slot="amls_tab"
|
||||
name="'amls_tab'"
|
||||
title.translate="Match Existing Entries"
|
||||
isVisible="['valid', 'invalid'].includes(data.state)">
|
||||
<t t-call="fusion_accounting.BankRecRecordNotebookAmls"/>
|
||||
</t>
|
||||
<t t-set-slot="manual_operations_tab"
|
||||
name="'manual_operations_tab'"
|
||||
title.translate="Manual Operations"
|
||||
isVisible="hasGroupReadOnly">
|
||||
<t t-call="fusion_accounting.BankRecRecordNotebookManualOperations"/>
|
||||
</t>
|
||||
<t t-set-slot="discuss_tab"
|
||||
name="'discuss_tab'"
|
||||
title.translate="Discuss"
|
||||
isVisible="true">
|
||||
<t t-call="fusion_accounting.BankRecRecordNotebookChatter"/>
|
||||
</t>
|
||||
<t t-set-slot="transaction_details_tab"
|
||||
name="'transaction_details_tab'"
|
||||
title.translate="Transaction Details"
|
||||
isVisible="data.st_line_transaction_details and data.st_line_transaction_details != ''">
|
||||
<t t-call="fusion_accounting.BankRecRecordNotebookTransactionDetails"/>
|
||||
</t>
|
||||
</Notebook>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.BankRecEmbeddedListController" t-inherit="web.ListView" t-inherit-mode="primary">
|
||||
<t t-set-slot="control-panel-additional-actions" position="replace"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,33 @@
|
||||
// Fusion Accounting - Bank Reconciliation Quick Create
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { KanbanRecordQuickCreate, KanbanQuickCreateController } from "@web/views/kanban/kanban_record_quick_create";
|
||||
|
||||
/**
|
||||
* BankRecQuickCreateController - Quick-create controller for adding
|
||||
* new bank statement lines directly from the kanban view.
|
||||
*/
|
||||
export class BankRecQuickCreateController extends KanbanQuickCreateController {
|
||||
static template = "account.BankRecQuickCreateController";
|
||||
}
|
||||
|
||||
export class BankRecQuickCreate extends KanbanRecordQuickCreate {
|
||||
static template = "account.BankRecQuickCreate";
|
||||
static props = {
|
||||
...Object.fromEntries(Object.entries(KanbanRecordQuickCreate.props).filter(([k, v]) => k !== 'group')),
|
||||
globalState: { type: Object, optional: true },
|
||||
};
|
||||
static components = { BankRecQuickCreateController };
|
||||
|
||||
/**
|
||||
Overriden.
|
||||
**/
|
||||
async getQuickCreateProps(props) {
|
||||
await super.getQuickCreateProps({...props,
|
||||
group: {
|
||||
resModel: props.globalState.quickCreateState.resModel,
|
||||
context: props.globalState.quickCreateState.context,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="account.BankRecQuickCreate">
|
||||
<BankRecQuickCreateController t-if="state.isLoaded" t-props="quickCreateProps" />
|
||||
</t>
|
||||
|
||||
<t t-name="account.BankRecQuickCreateController">
|
||||
<div
|
||||
class="o_bank_rec_quick_create o_kanban_record o_form_view w-100"
|
||||
t-ref="root"
|
||||
>
|
||||
<t t-component="props.Renderer" record="model.root" Compiler="props.Compiler" archInfo="props.archInfo"/>
|
||||
<div class="d-flex flex-wrap justify-content-end gap-1 button_group border-top-0 mt-2">
|
||||
<button class="btn btn-primary o_kanban_add me-1" t-on-click="() => this.validate('add')" data-hotkey="s">
|
||||
Add & New
|
||||
</button>
|
||||
<button class="btn btn-secondary o_kanban_edit me-1" t-on-click="() => this.validate('add_close')" data-hotkey="shift+s">
|
||||
Add & Close
|
||||
</button>
|
||||
<span class="flex-grow-1"></span>
|
||||
<button class="btn btn-secondary o_kanban_cancel" t-on-click="() => this.cancel(true)" data-hotkey="d">
|
||||
<i class="fa fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,92 @@
|
||||
// Fusion Accounting - Bank Reconciliation Record
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Record } from "@web/model/relational_model/record";
|
||||
import { RelationalModel } from "@web/model/relational_model/relational_model";
|
||||
import { parseServerValue } from "@web/model/relational_model/utils";
|
||||
|
||||
/**
|
||||
* BankRecRecord - Extended relational model record for bank reconciliation.
|
||||
* Tracks field changes on reconciliation widget lines and manages
|
||||
* the custom save/update lifecycle for the reconciliation form.
|
||||
*/
|
||||
export class BankRecRecord extends Record {
|
||||
|
||||
/**
|
||||
* override
|
||||
* Track the changed field on lines.
|
||||
*/
|
||||
async _update(changes) {
|
||||
if(this.resModel === "bank.rec.widget.line"){
|
||||
for(const fieldName of Object.keys(changes)){
|
||||
this.model.lineIdsChangedField = fieldName;
|
||||
}
|
||||
}
|
||||
return super._update(...arguments);
|
||||
}
|
||||
|
||||
async updateToDoCommand(methodName, args, kwargs) {
|
||||
this.dirty = true;
|
||||
|
||||
const onChangeFields = ["todo_command"];
|
||||
const changes = {
|
||||
todo_command: {
|
||||
method_name: methodName,
|
||||
args: args,
|
||||
kwargs: kwargs,
|
||||
},
|
||||
};
|
||||
|
||||
const localChanges = this._getChanges(
|
||||
{ ...this._changes, ...changes },
|
||||
{ withReadonly: true }
|
||||
);
|
||||
const otherChanges = await this.model._onchange(this.config, {
|
||||
changes: localChanges,
|
||||
fieldNames: onChangeFields,
|
||||
evalContext: this.evalContext,
|
||||
});
|
||||
|
||||
const data = { ...this.data, ...changes };
|
||||
for (const fieldName in otherChanges) {
|
||||
data[fieldName] = parseServerValue(this.fields[fieldName], otherChanges[fieldName]);
|
||||
}
|
||||
const applyChanges = () => {
|
||||
Object.assign(changes, this._parseServerValues(otherChanges, this.data));
|
||||
if (Object.keys(changes).length > 0) {
|
||||
this._applyChanges(changes);
|
||||
}
|
||||
};
|
||||
return { data, applyChanges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an action to be called when a field on lines changed.
|
||||
* @param {Function} callback: The action to call taking the changed field as parameter.
|
||||
*/
|
||||
bindActionOnLineChanged(callback){
|
||||
this._onUpdate = async () => {
|
||||
const lineIdsChangedField = this.model.lineIdsChangedField;
|
||||
if(lineIdsChangedField){
|
||||
this.model.lineIdsChangedField = null;
|
||||
await callback(lineIdsChangedField);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BankRecRelationalModel extends RelationalModel{
|
||||
setup(params, { action, dialog, notification, rpc, user, view, company }) {
|
||||
super.setup(...arguments);
|
||||
this.lineIdsChangedField = null;
|
||||
}
|
||||
|
||||
load({ values }) {
|
||||
this.root = this._createRoot(this.config, values);
|
||||
}
|
||||
|
||||
getInitialValues() {
|
||||
return this.root._getChanges(this.root.data, { withReadonly: true })
|
||||
}
|
||||
}
|
||||
BankRecRelationalModel.Record = BankRecRecord;
|
||||
@@ -0,0 +1,42 @@
|
||||
// Fusion Accounting - Bank Reconciliation Embedded List View
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { ListController } from "@web/views/list/list_controller";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
|
||||
/**
|
||||
* BankRecEmbeddedListController - Embedded list controller for the
|
||||
* reconciliation form. Removes export controls and manages domain
|
||||
* state for efficient re-rendering of matched journal items.
|
||||
*/
|
||||
export class BankRecEmbeddedListController extends ListController {
|
||||
/** Remove the Export Cog **/
|
||||
static template = "fusion_accounting.BankRecEmbeddedListController";
|
||||
}
|
||||
|
||||
|
||||
export class BankRecWidgetFormEmbeddedListModel extends listView.Model {
|
||||
setup(params, { action, dialog, notification, rpc, user, view, company }) {
|
||||
super.setup(...arguments);
|
||||
this.storedDomainString = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* the list of AMLs don't need to be fetched from the server every time the form view is re-rendered.
|
||||
* this disables the retrieval, while still ensuring that the search bar works.
|
||||
*/
|
||||
async load(params = {}) {
|
||||
const currentDomain = params.domain.toString();
|
||||
if (currentDomain !== this.storedDomainString) {
|
||||
this.storedDomainString = currentDomain;
|
||||
return super.load(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmbeddedListView = {
|
||||
...listView,
|
||||
Controller: BankRecEmbeddedListController,
|
||||
Model: BankRecWidgetFormEmbeddedListModel,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
// Fusion Accounting - Bank Reconciliation Finish Buttons
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* BankRecFinishButtons - Action buttons displayed upon completing
|
||||
* bank reconciliation, providing navigation to journal dashboard
|
||||
* or next reconciliation batch.
|
||||
*/
|
||||
export class BankRecFinishButtons extends Component {
|
||||
static template = "fusion_accounting.BankRecFinishButtons";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.breadcrumbs = useState(this.env.config.breadcrumbs);
|
||||
}
|
||||
|
||||
getJournalFilter() {
|
||||
// retrieves the searchModel's searchItem for the journal
|
||||
return Object.values(this.searchModel.searchItems).filter(i => i.type == "field" && i.fieldName == "journal_id")[0];
|
||||
}
|
||||
|
||||
get searchModel() {
|
||||
return this.env.searchModel;
|
||||
}
|
||||
|
||||
get otherFiltersActive() {
|
||||
const facets = this.searchModel.facets;
|
||||
const journalFilterItem = this.getJournalFilter();
|
||||
for (const facet of facets) {
|
||||
if (facet.groupId !== journalFilterItem.groupId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
const facets = this.searchModel.facets;
|
||||
const journalFilterItem = this.getJournalFilter();
|
||||
for (const facet of facets) {
|
||||
if (facet.groupId !== journalFilterItem.groupId) {
|
||||
this.searchModel.deactivateGroup(facet.groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
breadcrumbBackOrDashboard() {
|
||||
if (this.breadcrumbs.length > 1) {
|
||||
this.env.services.action.restore();
|
||||
} else {
|
||||
this.env.services.action.doAction("account.open_account_journal_dashboard_kanban", {clearBreadcrumbs: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="fusion_accounting.BankRecFinishButtons">
|
||||
<p t-if="otherFiltersActive" class="btn btn-primary" t-on-click="clearFilters">All Transactions</p>
|
||||
<p>
|
||||
<t t-set="linkName" t-value="breadcrumbs.length > 1 ? breadcrumbs.slice(-2)[0].name : 'Dashboard'"/>
|
||||
<a href="#" t-on-click.stop.prevent="breadcrumbBackOrDashboard">Back to <t t-out="linkName"/></a>
|
||||
</p>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,30 @@
|
||||
// Fusion Accounting - Bank Reconciliation Global Info
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { Component, onWillStart } from "@odoo/owl";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
/**
|
||||
* BankRecGlobalInfo - Displays journal-level summary information
|
||||
* including balance amounts during bank reconciliation.
|
||||
*/
|
||||
export class BankRecGlobalInfo extends Component {
|
||||
static template = "fusion_accounting.BankRecGlobalInfo";
|
||||
static props = {
|
||||
journalId: { type: Number },
|
||||
journalBalanceAmount: { type: String },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.hasGroupReadOnly = false;
|
||||
onWillStart(async () => {
|
||||
this.hasGroupReadOnly = await user.hasGroup("account.group_account_readonly");
|
||||
})
|
||||
}
|
||||
|
||||
/** Open the bank reconciliation report. **/
|
||||
actionOpenBankGL() {
|
||||
this.env.methods.actionOpenBankGL(this.props.journalId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting.BankRecGlobalInfo">
|
||||
<div t-if="props.journalId"
|
||||
class="journal-balance d-flex w-100"
|
||||
t-att-class="{'pe-none': !hasGroupReadOnly}"
|
||||
t-on-click="(ev) => hasGroupReadOnly ? this.actionOpenBankGL() : {}">
|
||||
<span class="btn flex-fill text-900 text-start ps-0 fw-bold fs-4 align-self-center">
|
||||
Balance
|
||||
</span>
|
||||
<span class="btn btn-link pe-0 fw-bold fs-4 align-self-center"
|
||||
t-if="props.journalBalanceAmount or props.journalBalanceAmount === 0"
|
||||
t-esc="props.journalBalanceAmount"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
.o_bank_rec_main_div {
|
||||
display: flex;
|
||||
background-color: $o-view-background-color;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.o_form_view.o_xxl_form_view.o_view_controller .o_form_view_container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_kanban_renderer {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.o_bank_rec_kanban_div{
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
--KanbanRecord-padding-h: #{$o-kanban-inside-hgutter * 1.5};
|
||||
--KanbanRecord-padding-v: #{$o-kanban-inside-vgutter};
|
||||
--KanbanRecord-margin-h: #{$o-kanban-record-margin};
|
||||
|
||||
.o_reward {
|
||||
.o_reward_rainbow_man {
|
||||
top: -25% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.journal-balance {
|
||||
margin: 0px var(--KanbanRecord-margin-h) -1px;
|
||||
padding: $o-kanban-inside-vgutter calc(var(--KanbanRecord-padding-h));
|
||||
min-height: $o-statbutton-height;
|
||||
}
|
||||
|
||||
.kanban-statement {
|
||||
margin: 0px var(--KanbanRecord-margin-h) -1px;
|
||||
padding: $o-kanban-inside-vgutter calc(var(--KanbanRecord-padding-h));
|
||||
.kanban-statement-subline {
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_st_line{
|
||||
margin: 0px var(--KanbanRecord-margin-h) (-$border-width) !important;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
.statement_separator .btn-sm {
|
||||
opacity: 100;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_bank_rec_selected_st_line {
|
||||
background-color: var(--table-bg);
|
||||
}
|
||||
|
||||
.statement_separator {
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
margin: 0;
|
||||
padding: 2px 0 0 0;
|
||||
border: 0;
|
||||
z-index: 2;
|
||||
background-color: transparent;
|
||||
|
||||
.btn-sm {
|
||||
position: relative;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
top: -21px;
|
||||
opacity: 0; // display: none doesn't work with tour
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_right_div {
|
||||
flex: 1 1 70%;
|
||||
width: 70%;
|
||||
border-left: 1px solid $border-color;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
|
||||
.o_content{
|
||||
.o_form_nosheet{
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
|
||||
.row {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
table tr td:first-child, table th:first-child {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.o_list_table {
|
||||
--ListRenderer-thead-padding-y: #{$table-cell-padding-y-sm};
|
||||
th {
|
||||
border-left: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_stats_buttons{
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
margin-top: -$o-sheet-vpadding;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid $border-color;
|
||||
height: fit-content;
|
||||
min-height: $o-statbutton-height;
|
||||
|
||||
.o_bank_rec_stats_buttons_aside_left{
|
||||
flex-grow: 0;
|
||||
display: flex !important;
|
||||
flex-flow: row nowrap;
|
||||
border-right: 1px solid $border-color;
|
||||
|
||||
.btn {
|
||||
margin-right: $o-statbutton-spacing;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_stats_buttons_aside_right{
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
overflow-y: hidden;
|
||||
justify-content: right;
|
||||
flex-grow: 1;
|
||||
margin: 0px !important;
|
||||
|
||||
.available_reco_model_ids {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.o_bank_rec_reco_models_widget_div{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 0;
|
||||
justify-content: flex-end;
|
||||
|
||||
.recon_model_button {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bank_rec_reco_model_dropdown {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bank_rec_widget_form_discuss_anchor, .bank_rec_widget_form_transaction_details_anchor{
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.o_form_label {
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.o_notebook {
|
||||
--notebook-margin-x: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-down(md) {
|
||||
.o_bank_rec_widget_kanban_view {
|
||||
--ControlPanel-border-bottom: initial;
|
||||
}
|
||||
|
||||
.o_bank_rec_main_div {
|
||||
.o_bank_rec_kanban_div {
|
||||
--KanbanRecord-margin-h: #{-$border-width};
|
||||
}
|
||||
|
||||
.o_bank_rec_kanban_div, .o_bank_rec_right_div {
|
||||
flex: 1 0 95vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-only(print) {
|
||||
.o_bank_rec_widget_kanban_view .o_control_panel {
|
||||
margin-bottom: $border-width;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_liquidity_line{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.o_bank_rec_auto_balance_line{
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.o_bank_rec_lines_widget_table{
|
||||
th{
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_model_button{
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.o_bank_rec_selected_line > td {
|
||||
background-color: var(--table-bg) !important;
|
||||
}
|
||||
|
||||
.o_bank_rec_expanded_line{
|
||||
border-bottom: transparent;
|
||||
td {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_second_line {
|
||||
td {
|
||||
padding-top: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.o_form_uri {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bank_rec_quick_create {
|
||||
.o_form_renderer.o_form_nosheet.o_form_editable.d-block {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.o_form_view {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&.o_kanban_record > div:not(.o_dropdown_kanban) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.o_inner_group {
|
||||
display: flex;
|
||||
|
||||
.o_cell:first-child {
|
||||
input,span {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.o_cell:nth-child(2) {
|
||||
input {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_wrap_field > :first-child {
|
||||
flex: 0 0 27%;
|
||||
}
|
||||
|
||||
.o_inner_group > .o_wrap_field:first-child > :first-child {
|
||||
flex: 0 0 17%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.o_bank_rec_quick_create.o_form_view {
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<!-- Kanban controller -->
|
||||
<t t-name="account.BankRecoKanbanController" t-inherit="web.KanbanView" t-inherit-mode="primary">
|
||||
<xpath expr="//Layout" position="attributes">
|
||||
<attribute name="className">'o_bank_rec_main_div overflow-auto'</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-component='props.Renderer']" position="after">
|
||||
<div class="o_bank_rec_right_div">
|
||||
<t t-if="state.bankRecStLineId and this.bankRecModel">
|
||||
<t t-call="fusion_accounting.BankRecRecordForm"/>
|
||||
</t>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<!-- Kanban record -->
|
||||
<t t-name="account.BankRecKanbanRecord" t-inherit="web.KanbanRecord" t-inherit-mode="primary">
|
||||
<xpath expr="//article" position="attributes">
|
||||
<attribute name="t-att-tabindex"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<!-- Kanban renderer -->
|
||||
<t t-name="account.BankRecKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
|
||||
<xpath expr="//div[@t-ref='root']" position="attributes">
|
||||
<attribute name="class" add="o_bank_rec_kanban_div" separator=" "/>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-as='groupOrRecord']" position="before">
|
||||
<t t-set="statementGroups" t-value="groups()"/>
|
||||
<BankRecGlobalInfo t-if="globalState.bankRecStLineId and globalState.journalId"
|
||||
journalId="globalState.journalId"
|
||||
journalBalanceAmount="globalState.journalBalanceAmount"/>
|
||||
<t t-if="globalState.quickCreateState.isVisible">
|
||||
<BankRecQuickCreate globalState="globalState"
|
||||
onValidate="(record, mode) => this.validateQuickCreate(record, mode)"
|
||||
onCancel="() => this.cancelQuickCreate()"
|
||||
quickCreateView="props.quickCreateState.view"/>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-else='']/KanbanRecord" position="before">
|
||||
<t t-set="recData" t-value="groupOrRecord.record.data"/>
|
||||
<t t-if="recData.statement_id
|
||||
and statementGroups.length
|
||||
and recData.statement_id[0] === statementGroups[0].id">
|
||||
<!-- remove the first statement from the list of statements -->
|
||||
<t t-set="stGroup" t-value="statementGroups.shift()"/>
|
||||
<t t-set="stClass" t-value="!(recData.statement_complete and recData.statement_valid) and 'text-danger' or ''"/>
|
||||
<span t-if="stGroup" class="kanban-statement d-flex text-truncate align-self-center fw-bold w-100">
|
||||
<span t-attf-class="{{stClass}} kanban-statement-subline flex-fill text-start" t-out="stGroup.name"/>
|
||||
<span t-on-click.stop.prevent="() => this.openStatementDialog(recData.statement_id[0])"
|
||||
t-attf-class="kanban-statement-subline btn btn-link"
|
||||
name="kanban-subline-clickable-amount"
|
||||
t-esc="stGroup.balance"/>
|
||||
</span>
|
||||
</t>
|
||||
</xpath>
|
||||
<t t-call="web.ActionHelper" position="replace">
|
||||
<div t-if="props.noContentHelp" class="o_view_nocontent">
|
||||
<div class="o_nocontent_help">
|
||||
<RainbowMan t-if="this.env.methods.showRainbowMan()" t-props="this.env.methods.getRainbowManContentProps()"/>
|
||||
<t t-else="">
|
||||
<t t-out="props.noContentHelp"/>
|
||||
<BankRecFinishButtons/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,43 @@
|
||||
// Fusion Accounting - Bank Reconciliation List View
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ListController } from "@web/views/list/list_controller";
|
||||
|
||||
import { useChildSubEnv } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* BankRecListController - List view controller for bank statement lines.
|
||||
* Manages the list display of statements and coordinates with the
|
||||
* kanban reconciliation form for processing individual lines.
|
||||
*/
|
||||
export class BankRecListController extends ListController {
|
||||
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
|
||||
this.skipKanbanRestore = {};
|
||||
|
||||
useChildSubEnv({
|
||||
skipKanbanRestoreNeeded: (stLineId) => this.skipKanbanRestore[stLineId],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override
|
||||
* Don't allow bank_rec_form to be restored with previous values since the statement line has changed.
|
||||
*/
|
||||
async onRecordSaved(record) {
|
||||
this.skipKanbanRestore[record.resId] = true;
|
||||
return super.onRecordSaved(...arguments);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const bankRecListView = {
|
||||
...listView,
|
||||
Controller: BankRecListController,
|
||||
}
|
||||
|
||||
registry.category("views").add("bank_rec_list", bankRecListView);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Fusion Accounting - Bank Reconciliation List View Switcher
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
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";
|
||||
|
||||
/**
|
||||
* ListViewSwitcher - Field widget that provides a toggle to switch
|
||||
* between kanban and list views within the bank reconciliation interface.
|
||||
*/
|
||||
export class ListViewSwitcher extends Component {
|
||||
static template = "fusion_accounting.ListViewSwitcher";
|
||||
static props = standardFieldProps;
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
/** Called when the Match/View button is clicked. **/
|
||||
switchView() {
|
||||
// Add a new search facet to restrict the results to the selected statement line.
|
||||
const searchItem = Object.values(this.env.searchModel.searchItems).find((i) => i.fieldName === "statement_line_id");
|
||||
const stLineId = this.props.record.resId;
|
||||
const autocompleteValue = {
|
||||
label: this.props.record.data.move_id[1],
|
||||
operator: "=",
|
||||
value: stLineId,
|
||||
}
|
||||
this.env.searchModel.addAutoCompletionValues(searchItem.id, autocompleteValue);
|
||||
|
||||
// Switch to the kanban.
|
||||
this.action.switchView("kanban", { skipRestore: this.env.skipKanbanRestoreNeeded(stLineId) });
|
||||
}
|
||||
|
||||
/** Give the button's label for the current record. **/
|
||||
get buttonLabel() {
|
||||
return this.props.record.data.is_reconciled ? _t("View") : _t("Match");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add('bank_rec_list_view_switcher', {component: ListViewSwitcher});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting.ListViewSwitcher">
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
t-on-click.stop="() => this.switchView()">
|
||||
<t t-out="buttonLabel"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,27 @@
|
||||
// Fusion Accounting - Bank Reconciliation Many2One Multi Edit
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Many2OneField, many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
/**
|
||||
* BankRecMany2OneMultiID - Extended Many2One field supporting multi-record
|
||||
* editing within the bank reconciliation view.
|
||||
*/
|
||||
export class BankRecMany2OneMultiID extends Many2OneField {
|
||||
|
||||
get Many2XAutocompleteProps() {
|
||||
const props = super.Many2XAutocompleteProps;
|
||||
if (this.props.record.selected && this.props.record.model.multiEdit) {
|
||||
props.context.active_ids = this.env.model.root.selection.map((r) => r.resId);
|
||||
}
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
export const bankRecMany2OneMultiID = {
|
||||
...many2OneField,
|
||||
component: BankRecMany2OneMultiID,
|
||||
};
|
||||
|
||||
registry.category("fields").add("bank_rec_list_many2one_multi_id", bankRecMany2OneMultiID);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Fusion Accounting - Bank Reconciliation Monetary Field
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { MonetaryField, monetaryField } from "@web/views/fields/monetary/monetary_field";
|
||||
|
||||
/**
|
||||
* BankRecMonetaryField - Extended monetary field that automatically
|
||||
* applies sign inversion for forced negative values during reconciliation.
|
||||
*/
|
||||
export class BankRecMonetaryField extends MonetaryField{
|
||||
static template = "fusion_accounting.BankRecMonetaryField";
|
||||
static props = {
|
||||
...MonetaryField.props,
|
||||
hasForcedNegativeValue: { type: Boolean },
|
||||
};
|
||||
|
||||
/** Override **/
|
||||
get inputOptions(){
|
||||
const options = super.inputOptions;
|
||||
const parse = options.parse;
|
||||
options.parse = (value) => {
|
||||
let parsedValue = parse(value);
|
||||
if (this.props.hasForcedNegativeValue) {
|
||||
parsedValue = -Math.abs(parsedValue);
|
||||
}
|
||||
return parsedValue;
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Override **/
|
||||
get value() {
|
||||
let value = super.value;
|
||||
if(this.props.hasForcedNegativeValue){
|
||||
value = Math.abs(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const bankRecMonetaryField = {
|
||||
...monetaryField,
|
||||
component: BankRecMonetaryField,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting.BankRecMonetaryField" t-inherit="web.MonetaryField" t-inherit-mode="primary">
|
||||
<xpath expr="//span[hasclass('o_input')]" position="before">
|
||||
<span t-if="props.hasForcedNegativeValue and currency and currency.position === 'after'">-</span>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//input" position="before">
|
||||
<span t-if="props.hasForcedNegativeValue and currency and currency.position === 'before'">-</span>
|
||||
</xpath>
|
||||
<xpath expr="//span[hasclass('o_monetary_ghost_value')]" position="before">
|
||||
<span class="opacity-0 mx-1" t-if="props.hasForcedNegativeValue">-</span>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,21 @@
|
||||
// Fusion Accounting - Bank Reconciliation Rainbowman
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { BankRecFinishButtons } from "./finish_buttons";
|
||||
import { Component, onWillUnmount } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* BankRecRainbowContent - Celebration content displayed when all
|
||||
* bank statement lines have been successfully reconciled.
|
||||
*/
|
||||
export class BankRecRainbowContent extends Component {
|
||||
static template = "fusion_accounting.BankRecRainbowContent";
|
||||
static components = { BankRecFinishButtons };
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
onWillUnmount(() => {
|
||||
this.env.methods.initReconCounter();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="fusion_accounting.BankRecRainbowContent">
|
||||
<t t-set="summary" t-value="this.env.methods.getCounterSummary()"/>
|
||||
<h2>Congrats, you're all done!</h2>
|
||||
<p>You reconciled <strong t-out="summary.counter"/><t t-if="summary.counter gt 1"> transactions in </t><t t-else=""> transaction in </t><strong t-out="summary.humanDuration"/>.
|
||||
<t t-if="summary.counter gt 1">
|
||||
<br/>That's on average <b t-out="summary.secondsPerTransaction"/> seconds per transaction.
|
||||
</t>
|
||||
</p>
|
||||
<BankRecFinishButtons/>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Fusion Accounting - Bank Reconciliation View Embedder
|
||||
// Copyright (C) 2026 Nexa Systems Inc.
|
||||
|
||||
import { View } from "@web/views/view";
|
||||
import { Component, useSubEnv } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* BankRecViewEmbedder - Wrapper component for embedding sub-views
|
||||
* within the bank reconciliation form, resetting the control panel
|
||||
* context for clean view rendering.
|
||||
*/
|
||||
export class BankRecViewEmbedder extends Component {
|
||||
static props = ["viewProps"];
|
||||
static template = "fusion_accounting.BankRecViewEmbedder";
|
||||
static components = { View };
|
||||
|
||||
setup() {
|
||||
// Little hack while better solution from framework js.
|
||||
// Reset the config, especially the ControlPanel which was coming from a parent form view.
|
||||
// It also reset the view switchers which was necessary to make them disappear.
|
||||
useSubEnv({
|
||||
config: {...this.env.methods},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting.BankRecViewEmbedder">
|
||||
<View t-props="props.viewProps"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
|
||||
<t t-name="fusion_accounting.inconsistent_statement_warning">
|
||||
Some
|
||||
<a t-on-click="(ev) => controller.reportAction(ev, 'bank_reconciliation_report_open_inconsistent_statements', warningParams)">statements</a>
|
||||
have a starting balance different from the previous ending balance.
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.has_bank_miscellaneous_move_lines">
|
||||
<span>
|
||||
"<t t-out="warningParams.args"/>" account balance is affected by
|
||||
<a t-on-click="(ev) => controller.reportAction(ev, 'open_bank_miscellaneous_move_lines')">journal items</a>
|
||||
which don't originate from a bank statement nor payment.
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_accounting.journal_balance">
|
||||
<span>
|
||||
The current balance in the
|
||||
<a t-on-click="(ev) => controller.reportAction(ev, 'action_redirect_to_general_ledger')">
|
||||
General Ledger
|
||||
<t t-out="warningParams.general_ledger_amount"/>
|
||||
</a>
|
||||
|
||||
doesn't match the balance of your
|
||||
<a t-on-click="(ev) => controller.reportAction(ev, 'action_redirect_to_bank_statement_widget')">
|
||||
last bank statement
|
||||
<t t-out="warningParams.last_bank_statement_amount"/>
|
||||
</a>
|
||||
, leading to an unexplained difference of
|
||||
<t t-out="warningParams.unexplained_difference"/>
|
||||
</span>
|
||||
</t>
|
||||
</templates>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user