Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -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",
});
}
}
});

View File

@@ -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,
}
})

View File

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

View 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 });

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

View 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 });

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

View File

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

View File

@@ -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 }));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]],
});
}
}

View File

@@ -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",
},
);
}
}

View File

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

View File

@@ -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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 },
],
});
}
}

View File

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

View File

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

View File

@@ -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) }`);
}
}
}
}

View File

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

View File

@@ -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",
},
);
}
}

View File

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

View File

@@ -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,
};
}

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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,
});
}
}

View File

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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);
}
}

View File

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

View File

@@ -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");
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
.account_report.aged_partner_balance {
.partner_trust { line-height: 20px }
td[data-expression_label='currency'] > .wrapper { justify-content: center }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&#8205;</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>

View File

@@ -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,
}
});
}
}

View File

@@ -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 &amp; New
</button>
<button class="btn btn-secondary o_kanban_edit me-1" t-on-click="() => this.validate('add_close')" data-hotkey="shift+s">
Add &amp; 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>

View File

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

View File

@@ -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,
};

View File

@@ -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});
}
}
}

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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});

View File

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

View File

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

View File

@@ -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,
};

View File

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

View File

@@ -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();
});
}
}

View File

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

View File

@@ -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},
});
}
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="fusion_accounting.DeferredFilterComparison" t-inherit="fusion_accounting.AccountReportFilterComparison">
<xpath expr="//DropdownItem[contains(@attrs, 'filter_comparison_same_period_last_year')]" position="replace"/>
<xpath expr="//DropdownItem[contains(@attrs, 'filter_comparison_custom')]" position="replace"/>
</t>
<t t-name="fusion_accounting.DeferredFilters" t-inherit="fusion_accounting.AccountReportFiltersCustomizable">
<xpath expr="//div[@id='filter_comparison']/t" position="replace">
<t t-call="fusion_accounting.DeferredFilterComparison"/>
</xpath>
<xpath expr="//div[@id='filter_extra_options']" position="before">
<t t-call="fusion_accounting.AccountReportDeferredGroupBy"/>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="fusion_accounting.AccountReportDeferredGroupBy">
<Dropdown>
<button class="btn btn-secondary">
<i class="fa fa-list me-1"/>Group by
<t t-if="controller.options.deferred_grouping_field === 'account_id'">
Account
</t>
<t t-elif="controller.options.deferred_grouping_field === 'product_category_id'">
Product Category
</t>
<t t-else="">
Product
</t>
</button>
<t t-set-slot="content">
<DropdownItem
class="{ 'selected': controller.options.deferred_grouping_field === 'account_id' }"
onSelected="() => this.filterClicked({ optionKey: 'deferred_grouping_field', optionValue: 'account_id', reload: true})"
>
Account
</DropdownItem>
<DropdownItem
class="{ 'selected': controller.options.deferred_grouping_field === 'product_category_id' }"
onSelected="() => this.filterClicked({ optionKey: 'deferred_grouping_field', optionValue: 'product_category_id', reload: true})"
>
Product Category
</DropdownItem>
<DropdownItem
class="{ 'selected': controller.options.deferred_grouping_field === 'product_id' }"
onSelected="() => this.filterClicked({ optionKey: 'deferred_grouping_field', optionValue: 'product_id', reload: true})"
>
Product
</DropdownItem>
</t>
</Dropdown>
</t>
</templates>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="fusion_accounting.deferred_report_warning_already_posted">
<span>
<a type="button" t-on-click="(ev) => controller.reportAction(ev, 'open_deferral_entries', {})">
Deferrals have already been generated.
</a>
The entry that will be generated will take them into account.
</span>
</t>
</templates>

View File

@@ -0,0 +1,37 @@
.account_report.depreciation_schedule {
table.striped {
> thead > tr:not(:first-child) {
> th:nth-child(2n+3) { background: inherit }
> th[data-expression_label='assets_date_from'],
> th[data-expression_label='assets_plus'],
> th[data-expression_label='assets_minus'],
> th[data-expression_label='assets_date_to'],
> th[data-expression_label='balance'] {
background: $o-gray-100
}
}
> tbody {
> tr:not(.line_level_0):not(.empty) {
> td:nth-child(2n+3) { background: inherit }
> td[data-expression_label='assets_date_from'],
> td[data-expression_label='assets_plus'],
> td[data-expression_label='assets_minus'],
> td[data-expression_label='assets_date_to'],
> td[data-expression_label='balance'] {
background: $o-gray-100
}
}
> tr.line_level_0
{
> td:nth-child(2n+3) { background: inherit }
> td[data-expression_label='assets_date_from'],
> td[data-expression_label='assets_plus'],
> td[data-expression_label='assets_minus'],
> td[data-expression_label='assets_date_to'],
> td[data-expression_label='balance'] {
background: $o-gray-300
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="fusion_accounting.DepreciationScheduleFilters" t-inherit="fusion_accounting.AccountReportFiltersCustomizable">
<xpath expr="//div[@id='filter_extra_options']" position="before">
<t t-call="fusion_accounting.DepreciationScheduleGroupBy"/>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="fusion_accounting.DepreciationScheduleGroupBy">
<Dropdown>
<button class="btn btn-secondary">
<i class="fa fa-list me-1"/>
<t t-if="controller.options.assets_grouping_field === 'account_id'">
Group by Account
</t>
<t t-elif="controller.options.assets_grouping_field === 'asset_group_id'">
Group by Asset Group
</t>
<t t-elif="controller.options.assets_grouping_field === 'none'">
No Grouping
</t>
</button>
<t t-set-slot="content">
<DropdownItem
class="{ 'selected': controller.options.assets_grouping_field === 'account_id' }"
onSelected="() => this.filterClicked({ optionKey: 'assets_grouping_field', optionValue: 'account_id', reload: true})"
>
Group By Account
</DropdownItem>
<DropdownItem
class="{ 'selected': controller.options.assets_grouping_field === 'asset_group_id' }"
onSelected="() => this.filterClicked({ optionKey: 'assets_grouping_field', optionValue: 'asset_group_id', reload: true})"
>
Group By Asset Group
</DropdownItem>
<DropdownItem
class="{ 'selected': controller.options.assets_grouping_field === 'none'}"
onSelected="() => this.filterClicked({ optionKey: 'assets_grouping_field', optionValue: 'none', reload: true})"
>
No Grouping
</DropdownItem>
</t>
</Dropdown>
</t>
</templates>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="fusion_accounting.GeneralLedgerLineName" t-inherit="fusion_accounting.AccountReportLineNameCustomizable">
<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, 'open_journal_items', {
line_id: props.line.id,
view_ref: 'account.view_move_line_tree_grouped_partner',
})"
>
Journal Items
</button>
</t>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,36 @@
// Fusion Accounting - Journal Dashboard Activity
// Copyright (C) 2026 Nexa Systems Inc.
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { JournalDashboardActivity, journalDashboardActivity } from "@account/components/journal_dashboard_activity/journal_dashboard_activity";
/**
* JournalDashboardActivityTaxReport - Extended journal dashboard activity
* widget with tax report navigation and closing entry generation.
*/
export class JournalDashboardActivityTaxReport extends JournalDashboardActivity {
setup() {
super.setup();
this.orm = useService("orm");
}
async openActivity(activity) {
if (activity.activity_category === 'tax_report') {
const act = await this.orm.call("mail.activity", "action_open_tax_activity", [activity.id], {});
this.action.doAction(act);
} else {
super.openActivity(activity);
}
}
}
export const journalDashboardActivityTaxReport = {
...journalDashboardActivity,
component: JournalDashboardActivityTaxReport,
};
registry
.category("fields")
.add("kanban_vat_activity", journalDashboardActivityTaxReport, { force: true });

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