diff --git a/fusion_accounting_bank_rec/__manifest__.py b/fusion_accounting_bank_rec/__manifest__.py index d81cede4..6f4a67e3 100644 --- a/fusion_accounting_bank_rec/__manifest__.py +++ b/fusion_accounting_bank_rec/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting — Bank Reconciliation', - 'version': '19.0.1.0.12', + 'version': '19.0.1.0.13', 'category': 'Accounting/Accounting', 'sequence': 28, 'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.', @@ -56,6 +56,18 @@ Built by Nexa Systems Inc. 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.xml', 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.js', 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.xml', + # Batch 2 (Task 31) — action + edit components + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml', ], }, 'installable': True, diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js new file mode 100644 index 00000000..4eba3f9b --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js @@ -0,0 +1,82 @@ +/** @odoo-module **/ + +/** + * Mirrored from `account_accountant/.../apply_amount/apply_amount.js`. + * Phase 1 structural parity. + */ + +import { Component } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +class BankRecWidgetApplyAmountHtmlField extends Component { + static props = standardFieldProps; + static template = "fusion_accounting_bank_rec.BankRecWidgetApplyAmountHtmlField"; + + setup() { + this.action = useService("action"); + this.orm = useService("orm"); + } + + get value() { + return this.props.record.data[this.props.name]; + } + + async switchApplyAmount(ev) { + const root = this.env.model.root; + const fetchReconciledLines = async (fields = []) => { + return await this.orm.searchRead( + "account.move.line", + [ + [ + "id", + "in", + ...root.data.reconciled_lines_excluding_exchange_diff_ids._currentIds, + ], + ], + fields + ); + }; + + const fetchStatementLines = async (fields = []) => { + return await this.orm.searchRead( + "account.move.line", + [["move_id", "=", root.data.move_id.id]], + fields + ); + }; + + if (ev.target.attributes.name?.value === "action_redirect_to_move") { + const [line] = await fetchReconciledLines(["amount_currency", "balance", "move_id"]); + await this.openMove(line.move_id[0]); + } else if (ev.target.attributes.name?.value === "apply_full_amount") { + const [line] = await fetchReconciledLines(["amount_currency", "balance"]); + await root.update({ + balance: -line.balance, + amount_currency: -line.amount_currency, + }); + } else if (ev.target.attributes.name?.value === "apply_partial_amount") { + const lines = await fetchStatementLines(["amount_currency", "balance"]); + // We have all the lines of the entry, we want the suspense line. + await root.update({ + balance: lines.at(-1).balance, + amount_currency: lines.at(-1).amount_currency, + }); + } + } + + openMove(moveId) { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "account.move", + res_id: moveId, + views: [[false, "form"]], + target: "current", + }); + } +} + +const fusionBankRecWidgetApplyAmountHtmlField = { component: BankRecWidgetApplyAmountHtmlField }; + +registry.category("fields").add("fusion_apply_amount_html", fusionBankRecWidgetApplyAmountHtmlField); diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml new file mode 100644 index 00000000..784cb753 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js new file mode 100644 index 00000000..11f4a802 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js @@ -0,0 +1,29 @@ +/** @odoo-module **/ + +/** + * Mirrored from `account_accountant/.../button/button.js`. + * Phase 1 structural parity. + */ + +import { Component } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +export class BankRecButton extends Component { + static template = "fusion_accounting_bank_rec.BankRecButton"; + static props = { + label: { type: String, optional: true }, + action: { type: Function, optional: true }, + count: { type: [Number, { value: null }], optional: true }, + primary: { type: Boolean, optional: true }, + toReview: { type: Boolean, optional: true }, + classes: { type: String, optional: true }, + }; + static defaultProps = { + primary: false, + classes: "", + }; + + setup() { + this.ui = useService("ui"); + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml new file mode 100644 index 00000000..99a60743 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js new file mode 100644 index 00000000..8c0f662b --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js @@ -0,0 +1,603 @@ +/** @odoo-module **/ + +/** + * Mirrored from `account_accountant/.../button_list/button_list.js`. + * Phase 1 structural parity. Behaviour delegates to the + * Enterprise-compat surface in our `fusion_bank_reconciliation` service. + */ + +import { BankRecButton } from "@fusion_accounting_bank_rec/components/bank_reconciliation/button/button"; +import { BankRecFileUploader } from "@fusion_accounting_bank_rec/components/bank_reconciliation/file_uploader/file_uploader"; +import { Component } from "@odoo/owl"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog"; +import { BankRecSelectCreateDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/search_dialog/search_dialog"; +import { _t } from "@web/core/l10n/translation"; +import { getCurrency } from "@web/core/currency"; +import { useOwnedDialogs, useService } from "@web/core/utils/hooks"; +import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service"; +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; + +export class BankRecButtonList extends Component { + static template = "fusion_accounting_bank_rec.BankRecButtonList"; + static components = { + Dropdown, + DropdownItem, + BankRecButton, + BankRecFileUploader, + }; + static props = { + statementLineRootRef: { type: Object }, + statementLine: { type: Object }, + suspenseAccountLine: { type: Object, optional: true }, + reconcileLineCount: { type: [Number, { value: null }], optional: true }, + reconcileModels: Array, + preSelectedReconciliationModel: { type: Object, optional: true }, + }; + static defaultProps = { + reconcileLineCount: 0, + }; + + setup() { + this.action = useService("action"); + this.ui = useService("ui"); + this.orm = useService("orm"); + + this.addDialog = useOwnedDialogs(); + this.currencyDigits = getCurrency(this.statementLineData.currency_id.id)?.digits || 2; + this.bankReconciliation = useBankReconciliation(); + + this.registerHotkeys(); + } + + restoreFocus() { + if (this.isLineSelected) { + this.props.statementLineRootRef.el.focus(); + } + } + + /** + * Displays a search dialog (no create option) for selecting a `res.partner` record. + */ + setPartnerOnReconcileLine() { + this.addDialog( + SelectCreateDialog, + { + title: _t("Search: Partner"), + noCreate: false, + multiSelect: false, + resModel: "res.partner", + context: { default_name: this.statementLineData.partner_name }, + onSelected: async (partner) => { + await this.orm.call( + "account.bank.statement.line", + "set_partner_bank_statement_line", + [this.statementLineData.id, partner[0]] + ); + const recordsToLoad = []; + if (this.statementLineData.partner_name) { + // Reload all impacted statement lines if we have a partner_name + recordsToLoad.push( + ...this.env.model.root.records.filter( + (record) => + record.data.partner_name === this.statementLineData.partner_name + ) + ); + } else { + recordsToLoad.push(this.props.statementLine); + } + await this.bankReconciliation.reloadRecords(recordsToLoad); + await this.bankReconciliation.computeReconcileLineCountPerPartnerId( + this.env.model.root.records + ); + this.bankReconciliation.reloadChatter(); + this.restoreFocus(); + }, + }, + { + onClose: () => { + this.restoreFocus(); + }, + } + ); + } + + /** + * Opens a dialog to select an account and assigns it to the current reconcile line. + */ + setAccountOnReconcileLine() { + const context = { + list_view_ref: "account_accountant.view_account_list_bank_rec_widget", + search_view_ref: "account_accountant.view_account_search_bank_rec_widget", + ...(this.statementLineData.amount > 0 + ? { preferred_account_type: "income" } + : { preferred_account_type: "expense" }), + }; + + this.addDialog( + SelectCreateDialog, + { + title: _t("Search: Account"), + noCreate: true, + multiSelect: false, + domain: [ + [ + "id", + "not in", + [ + this.statementLineData.journal_id.suspense_account_id.id, + this.statementLineData.journal_id.default_account_id.id, + ], + ], + ], + context: context, + resModel: "account.account", + onSelected: async (account) => { + const linesToLoad = await this._setAccountOnReconcileLine( + this.lastAccountMoveLine.data.id, + account[0], + { context: { account_default_taxes: true } } + ); + const recordsToLoad = [ + ...this.env.model.root.records.filter((record) => + linesToLoad.includes(record.data.id) + ), + this.props.statementLine, + ]; + await this.bankReconciliation.reloadRecords(recordsToLoad); + this.bankReconciliation.reloadChatter(); + this.restoreFocus(); + }, + }, + { + onClose: () => { + this.restoreFocus(); + }, + } + ); + } + + async _setAccountOnReconcileLine(amlId, accountId, context = {}) { + return await this.orm.call( + "account.bank.statement.line", + "set_account_bank_statement_line", + [this.statementLineData.id, amlId, accountId], + context + ); + } + + async setAccountReceivableOnReconcileLine() { + let accountId; + if (this.statementLineData.partner_id.property_account_receivable_id.id) { + accountId = this.statementLineData.partner_id.property_account_receivable_id.id; + } else { + accountId = await this.orm.webSearchRead("account.account", [ + ["account_type", "=", "asset_receivable"], + ]); + } + await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId); + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + } + + async setAccountPayableOnReconcileLine() { + let accountId; + if (this.statementLineData.partner_id.property_account_payable_id.id) { + accountId = this.statementLineData.partner_id.property_account_payable_id.id; + } else { + accountId = await this.orm.webSearchRead("account.account", [ + ["account_type", "=", "liability_payable"], + ]); + } + await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId); + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + } + + /** + * Opens a dialog to search and select journal items to reconcile with the current bank statement line. + */ + reconcileOnReconcileLine() { + const context = { + list_view_ref: "account_accountant.view_account_move_line_list_bank_rec_widget", + search_view_ref: "account_accountant.view_account_move_line_search_bank_rec_widget", + preferred_aml_value: -this.props.suspenseAccountLine.amount_currency, + preferred_aml_currency_id: this.props.suspenseAccountLine.currency_id.id, + ...(this.statementLineData.partner_id + ? { search_default_partner_id: this.statementLineData.partner_id.id } + : { search_default_posted: 1 }), + }; + + this.addDialog( + BankRecSelectCreateDialog, + { + title: _t("Search: Journal Items to Match"), + noCreate: true, + domain: this.getReconcileButtonDomain(), + resModel: "account.move.line", + size: "xl", + context: context, + onSelected: async (moveLines) => { + await this.orm.call( + "account.bank.statement.line", + "set_line_bank_statement_line", + [this.statementLineData.id, moveLines] + ); + await this.bankReconciliation.computeReconcileLineCountPerPartnerId( + this.env.model.root.records + ); + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + this.restoreFocus(); + }, + suspenseAccountLine: this.props.suspenseAccountLine, + reference: this.statementLineData.payment_ref, + date: this.statementLineData.date, + }, + { + onClose: () => { + this.restoreFocus(); + }, + } + ); + } + + getReconcileButtonDomain() { + return [ + ["parent_state", "in", ["draft", "posted"]], + ["company_id", "child_of", this.statementLineData.company_id.id], + ["search_account_id.reconcile", "=", true], + ["display_type", "not in", ["line_section", "line_note"]], + ["reconciled", "=", false], + "|", + ["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]], + ["payment_id", "=", false], + ["statement_line_id", "!=", this.statementLineData.id], + ]; + } + + /** + * Deletes the current bank statement line. + */ + async deleteTransaction() { + this.addDialog(ConfirmationDialog, { + body: _t("Are you sure you want to delete this statement line?"), + confirm: async () => { + await this.orm.unlink("account.bank.statement.line", [this.statementLineData.id]); + this.env.model.load(); + }, + cancel: () => {}, + }); + } + + /** + * Set the move of the statement line as to check + */ + async setStatementLineAsReviewed() { + await this.orm.call("account.move", "set_moves_checked", [ + this.statementLineData.move_id.id, + ]); + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + } + + // ----------------------------------------------------------------------------- + // Reconciliation Model + // ----------------------------------------------------------------------------- + async triggerReconciliationModel(reconciliationModelId) { + await this.orm.call("account.reconcile.model", "trigger_reconciliation_model", [ + reconciliationModelId, + this.statementLineData.id, + ]); + await this.bankReconciliation.computeReconcileLineCountPerPartnerId( + this.env.model.root.records + ); + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + } + + getKeyAction(key) { + const keyActions = { + 1: { + condition: + this.props.statementLineRootRef.el.querySelector(".set-partner-btn") && + this.isLineSelected, + action: async () => this.setPartnerOnReconcileLine(), + buttonElement: this.props.statementLineRootRef.el.querySelector(".set-partner-btn"), + }, + 2: { + condition: + this.props.statementLineRootRef.el.querySelector(".reconcile-btn") && + this.isLineSelected, + action: async () => this.reconcileOnReconcileLine(), + buttonElement: this.props.statementLineRootRef.el.querySelector(".reconcile-btn"), + }, + 3: { + condition: + this.props.statementLineRootRef.el.querySelector(".set-account-btn") && + this.isLineSelected, + action: () => this.setAccountOnReconcileLine(), + buttonElement: this.props.statementLineRootRef.el.querySelector(".set-account-btn"), + }, + 4: { + condition: + this.props.statementLineRootRef.el.querySelector(".set-payable-btn") && + this.isLineSelected, + action: () => this.setAccountPayableOnReconcileLine(), + buttonElement: this.props.statementLineRootRef.el.querySelector(".set-payable-btn"), + }, + 5: { + condition: + this.props.statementLineRootRef.el.querySelector(".set-receivable-btn") && + this.isLineSelected, + action: () => this.setAccountReceivableOnReconcileLine(), + buttonElement: + this.props.statementLineRootRef.el.querySelector(".set-receivable-btn"), + }, + 6: { + condition: + this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-0" + ) && this.isLineSelected, + action: () => { + const buttonElement = this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-0" + ); + if (buttonElement) { + buttonElement.click(); + } + }, + buttonElement: this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-0" + ), + }, + 7: { + condition: + this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-1" + ) && this.isLineSelected, + action: () => { + const buttonElement = this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-1" + ); + if (buttonElement) { + buttonElement.click(); + } + }, + buttonElement: this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-1" + ), + }, + 8: { + condition: + this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-2" + ) && this.isLineSelected, + action: () => { + const buttonElement = this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-2" + ); + if (buttonElement) { + buttonElement.click(); + } + }, + buttonElement: this.props.statementLineRootRef.el.querySelector( + ".reconciliation-model-btn-2" + ), + }, + Enter: { + condition: + this.props.statementLineRootRef.el.querySelector(".btn-primary") && + this.isLineSelected, + action: () => { + const primaryButtons = + this.props.statementLineRootRef.el.querySelectorAll(".btn-primary"); + if (primaryButtons.length > 0) { + primaryButtons[0].click(); + } + }, + buttonElement: this.props.statementLineRootRef.el.querySelector(".btn-primary"), + }, + }; + return keyActions[key]; + } + + registerHotkeys() { + const hotkeyConfigs = [ + { key: "1", trigger: "alt+shift+1" }, + { key: "2", trigger: "alt+shift+2" }, + { key: "3", trigger: "alt+shift+3" }, + { key: "4", trigger: "alt+shift+4" }, + { key: "5", trigger: "alt+shift+5" }, + { key: "6", trigger: "alt+shift+6" }, + { key: "7", trigger: "alt+shift+7" }, + { key: "8", trigger: "alt+shift+8" }, + { key: "Enter", trigger: "alt+shift+enter" }, + ]; + hotkeyConfigs.forEach(({ key, trigger }) => { + useHotkey( + trigger, + ({ target }) => { + const { condition, action } = this.getKeyAction(key); + if (condition) { + action(); + } + }, + { + area: () => this.props.statementLineRootRef.el.parentElement, + withOverlay: () => { + const { buttonElement, condition } = this.getKeyAction(key); + return condition ? buttonElement : null; + }, + isAvailable: () => { + const { condition } = this.getKeyAction(key); + return condition; + }, + } + ); + }); + } + + // ----------------------------------------------------------------------------- + // File Uploader + // ----------------------------------------------------------------------------- + get bankRecFileUploaderRecord() { + return { + statementLineId: this.statementLineData.id, + }; + } + + // ----------------------------------------------------------------------------- + // ACTION + // ----------------------------------------------------------------------------- + actionViewRecoModels() { + return this.action.doAction("account.action_account_reconcile_model"); + } + + // ----------------------------------------------------------------------------- + // GETTER + // ----------------------------------------------------------------------------- + get statementLineData() { + return this.props.statementLine.data; + } + + get isLineSelected() { + return this.statementLineData.id === this.bankReconciliation.statementLine?.data.id; + } + + get lastAccountMoveLine() { + return this.statementLineData.line_ids.records.at(-1); + } + + get isCustomerRankHigher() { + return ( + this.statementLineData.partner_id.customer_rank > + this.statementLineData.partner_id.supplier_rank + ); + } + + get isSetPartnerButtonShown() { + return !this.statementLineData.partner_id; + } + + get isSetAccountButtonShown() { + return !this.statementLineData.account_id; + } + + get isSetReceivableButtonShown() { + return ( + !this.isSetPartnerButtonShown && + ((this.statementLineData.partner_id.customer_rank && this.isCustomerRankHigher) || + this.statementLineData.amount > 0) + ); + } + + get isSetPayableButtonShown() { + return ( + !this.isSetPartnerButtonShown && + ((this.statementLineData.partner_id.supplier_rank && !this.isCustomerRankHigher) || + this.statementLineData.amount < 0) + ); + } + + get isReconcileButtonShown() { + return this.props.reconcileLineCount === null || this.props.reconcileLineCount; + } + + get reconcileModelsInDropdown() { + if (this.ui.isSmall) { + return this.props.reconcileModels; + } + return this.props.reconcileModels.filter( + (model) => model.id !== this.props?.preSelectedReconciliationModel?.id + ); + } + + get buttons() { + const buttonsToDisplay = {}; + if (this.isSetPartnerButtonShown) { + buttonsToDisplay.partner = { + label: _t("Set Partner"), + action: this.setPartnerOnReconcileLine.bind(this), + classes: "set-partner-btn", + }; + } else { + buttonsToDisplay.receivable = { + label: _t("Receivable"), + action: this.setAccountReceivableOnReconcileLine.bind(this), + classes: "set-receivable-btn", + }; + buttonsToDisplay.payable = { + label: _t("Payable"), + action: this.setAccountPayableOnReconcileLine.bind(this), + classes: "set-payable-btn", + }; + } + + if (this.isReconcileButtonShown) { + buttonsToDisplay.reconcile = { + label: _t("Reconcile"), + action: this.reconcileOnReconcileLine.bind(this), + count: this.props.reconcileLineCount, + classes: "reconcile-btn", + }; + } + + if (this.isSetAccountButtonShown) { + buttonsToDisplay.account = { + label: _t("Set Account"), + action: this.setAccountOnReconcileLine.bind(this), + classes: "set-account-btn", + }; + } + + if (this.statementLineData.is_reconciled && !this.statementLineData.checked) { + buttonsToDisplay.toReview = { + label: _t("Reviewed"), + action: this.setStatementLineAsReviewed.bind(this), + toReview: true, + }; + } + + return buttonsToDisplay; + } + + get buttonsToDisplay() { + const buttons = this.buttons || {}; + + let primaryButtonKeys = []; + let secondaryButtonKeys = []; + if (buttons?.partner && buttons?.account) { + primaryButtonKeys = ["partner", "account"]; + } else if (buttons?.reconcile && !!buttons.reconcile?.count) { + primaryButtonKeys = ["reconcile"]; + if (this.isSetReceivableButtonShown) { + secondaryButtonKeys = ["receivable"]; + } else { + secondaryButtonKeys = ["payable"]; + } + } else if (this.isSetReceivableButtonShown) { + primaryButtonKeys = ["receivable"]; + } else if (this.isSetPayableButtonShown) { + primaryButtonKeys = ["payable"]; + } + + return [ + ...primaryButtonKeys.map((key) => ({ ...buttons[key], primary: true })), + ...secondaryButtonKeys.map((key) => ({ ...buttons[key] })), + ]; + } + + get buttonsInDropdown() { + const buttons = this.buttons || {}; + if (this.props.preSelectedReconciliationModel) { + return Object.values(buttons); + } + const buttonToDisplayClasses = this.buttonsToDisplay.map((button) => button.classes) || []; + return Object.values(buttons).filter( + (button) => !buttonToDisplayClasses.includes(button.classes) + ); + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml new file mode 100644 index 00000000..1312ff54 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Upload Bills + + + + + + + + + + + + Manage Models + + + Delete Transaction + + + + + + diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js new file mode 100644 index 00000000..5f332530 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js @@ -0,0 +1,204 @@ +/** @odoo-module **/ + +/** + * Mirrored from + * `account_accountant/.../line_to_reconcile/line_to_reconcile.js`. + * Phase 1 structural parity. + */ + +import { Component, useRef } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { useService } from "@web/core/utils/hooks"; +import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { BankRecFormDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog"; +import { BankRecLineInfoPopOver } from "@fusion_accounting_bank_rec/components/bank_reconciliation/line_info_pop_over/line_info_pop_over"; +import { x2ManyCommands } from "@web/core/orm_service"; + +export class BankRecLineToReconcile extends Component { + static template = "fusion_accounting_bank_rec.BankRecLineToReconcile"; + + static props = { + line: Object, + statementLine: Object, + }; + + setup() { + this.action = useService("action"); + this.orm = useService("orm"); + this.dialogService = useService("dialog"); + this.ui = useService("ui"); + this.bankReconciliation = useBankReconciliation(); + + this.lineInfoRef = useRef("line-info-ref"); + this.lineInfoPopOver = usePopover(BankRecLineInfoPopOver, { + position: "left", + closeOnClickAway: true, + }); + } + + onClickLine() { + if (this.ui.isSmall) { + this.toggleEditLine(); + } + } + + toggleEditLine() { + this.dialogService.add(BankRecFormDialog, { + title: _t("Edit Line"), + resModel: "account.move.line", + resId: this.lineData.id, + context: { + form_view_ref: "account_accountant.view_bank_rec_edit_line", + is_reviewed: this.lineData.move_id.checked, + }, + onRecordSave: async (record) => { + await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [ + this.statementLineData.id, + this.lineData.id, + await record.getChanges(), + ]); + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + return true; + }, + }); + } + + async deleteLine() { + await this.orm.call("account.bank.statement.line", "delete_reconciled_line", [ + this.statementLineData.id, + this.lineData.id, + ]); + if (this.lineData.reconciled_lines_ids.records.length) { + // Only update the line count per partner if we delete + // a line which is reconciled to another move line + this.bankReconciliation.computeReconcileLineCountPerPartnerId( + this.env.model.root.records + ); + } + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + } + + // ----------------------------------------------------------------------------- + // ACTION + // ----------------------------------------------------------------------------- + openMove() { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "account.move", + res_id: this.moveData.id, + views: [[false, "form"]], + target: "current", + }); + } + + openPartner() { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "res.partner", + res_id: this.lineData.partner_id.id, + views: [[false, "form"]], + target: "current", + }); + } + + openLineInfoPopOver() { + if (this.lineInfoPopOver.isOpen || !this.showLineInfo) { + this.lineInfoPopOver.close(); + } else { + this.lineInfoPopOver.open(this.lineInfoRef.el, { + statementLineData: this.statementLineData, + lineData: this.lineData, + exchangeMove: this.exchangeMove, + isPartiallyReconciled: this.isPartiallyReconciled, + }); + } + } + + async deleteTax(taxIndex) { + const taxChanged = this.lineDataTaxIds[taxIndex]; + await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [ + this.statementLineData.id, + this.lineData.id, + { tax_ids: [[x2ManyCommands.UNLINK, taxChanged.data.id]] }, + ]); + this.props.statementLine.load(); + this.bankReconciliation.reloadChatter(); + } + + // ----------------------------------------------------------------------------- + // GETTER + // ----------------------------------------------------------------------------- + get statementLineData() { + return this.props.statementLine.data; + } + + get lineData() { + return this.props.line; + } + + get reconciledLineId() { + return this.lineData.reconciled_lines_ids.records.length === 1 + ? this.lineData.reconciled_lines_ids.records[0].data + : null; + } + + get reconciledLineExcludingExchangeDiffId() { + return this.lineData.reconciled_lines_excluding_exchange_diff_ids.records.length === 1 + ? this.lineData.reconciled_lines_excluding_exchange_diff_ids.records[0].data + : null; + } + + get moveData() { + return ( + this.reconciledLineId?.move_id || + this.reconciledLineExcludingExchangeDiffId?.move_id || + this.lineData.move_id + ); + } + + get isPartiallyReconciled() { + if (!this.reconciledLineId) { + return false; + } + return !this.reconciledLineId.full_reconcile_id?.id; + } + + get hasDifferentCurrencies() { + return this.lineData.currency_id.id !== this.statementLineData.currency_id.id; + } + + get formattedAmountCurrencyOfLine() { + return formatMonetary(this.lineData.amount_currency, { + currencyId: this.lineData.currency_id.id, + }); + } + + get formattedAmountCurrencyOfStatementLine() { + return formatMonetary(this.lineData.amount_currency, { + currencyId: this.statementLineData.currency_id.id, + }); + } + + get exchangeMove() { + return ( + this.lineData.matched_debit_ids.records[0]?.data.exchange_move_id || + this.lineData.matched_credit_ids.records[0]?.data.exchange_move_id + ); + } + + get showLineInfo() { + return this.isPartiallyReconciled || this.exchangeMove?.id; + } + + get isTaxLine() { + return this.lineData.tax_line_id; + } + + get lineDataTaxIds() { + return this.lineData.tax_ids.records; + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml new file mode 100644 index 00000000..730f37fd --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js new file mode 100644 index 00000000..a99ca96b --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js @@ -0,0 +1,88 @@ +/** @odoo-module **/ + +/** + * Mirrored from `account_accountant/.../list_view/list.js`. + * Phase 1 structural parity. + * + * NOTE: Enterprise extends `AttachmentPreviewListController` from + * `account_accountant/static/src/components/attachment_preview_list_view/...`. + * That helper isn't part of Phase 1 scope; we extend the base + * `ListController` directly and TODO-flag the methods that depend on + * the previewer state. Behaviour will be wired up in fusion-only + * Tasks 34-36 alongside the right-pane preview integration. + */ + +import { ListController } from "@web/views/list/list_controller"; +import { ListRenderer } from "@web/views/list/list_renderer"; +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; +import { useChildSubEnv } from "@odoo/owl"; +import { makeActiveField } from "@web/model/relational_model/utils"; + +export class BankRecListController extends ListController { + setup() { + super.setup(...arguments); + + this.skipKanbanRestore = {}; + + useChildSubEnv({ + skipKanbanRestoreNeeded: (stLineId) => this.skipKanbanRestore[stLineId], + }); + } + + /** + * 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); + } + + get previewerStorageKey() { + return "fusion.statement_line_pdf_previewer_hidden"; + } + + get modelParams() { + const params = super.modelParams; + params.config.activeFields.bank_statement_attachment_ids = makeActiveField(); + params.config.activeFields.bank_statement_attachment_ids.related = { + fields: { + mimetype: { name: "mimetype", type: "char" }, + }, + activeFields: { + mimetype: makeActiveField(), + }, + }; + params.config.activeFields.attachment_ids = makeActiveField(); + params.config.activeFields.attachment_ids.related = { + fields: { + mimetype: { name: "mimetype", type: "char" }, + }, + activeFields: { + mimetype: makeActiveField(), + }, + }; + return params; + } + + /** + * TODO(fusion task 34-36): wire up attachment preview pane. + * Enterprise sets `this.attachmentPreviewState.selectedRecord` and + * calls `this.setThread(...)` on the AttachmentPreviewListController. + * Until that helper is mirrored, this is a no-op. + */ + async setSelectedRecord(/* accountBankStatementLineData */) { + return; + } +} + +export class BankRecListRenderer extends ListRenderer {} + +export const bankRecListView = { + ...listView, + Controller: BankRecListController, + Renderer: BankRecListRenderer, +}; + +registry.category("views").add("fusion_bank_rec_list", bankRecListView); diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js new file mode 100644 index 00000000..6fb0e328 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js @@ -0,0 +1,30 @@ +/** @odoo-module **/ + +/** + * Mirrored from + * `account_accountant/.../list_view/list_view_many2one_multi_edit.js`. + * Phase 1 structural parity. + */ + +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one"; +import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field"; + +export class BankRecMany2OneMultiID extends Component { + static template = "fusion_accounting_bank_rec.BankRecMany2OneMultiID"; + static components = { Many2One }; + static props = { ...Many2OneField.props }; + + get m2oProps() { + const props = computeM2OProps(this.props); + 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; + } +} + +registry.category("fields").add("fusion_bank_rec_list_many2one_multi_id", { + ...buildM2OFieldDescription(BankRecMany2OneMultiID), +}); diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml new file mode 100644 index 00000000..848c2ae4 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml @@ -0,0 +1,6 @@ + + + + + +