From 64b2d2ba5250541a9feeb92ffc351256306a61f1 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 24 Aug 2023 20:18:44 +0530 Subject: [PATCH] feat: Bank Reconciliation Tool refactor - UI: Two pane view, action tabs - One step invoice reconciliation - Improved APIs with more optional filters and more data from Bank Transactions --- .pre-commit-config.yaml | 34 +- .../bank_reconciliation_tool_beta/__init__.py | 0 .../bank_reconciliation_tool_beta.js | 238 ++++ .../bank_reconciliation_tool_beta.json | 142 +++ .../bank_reconciliation_tool_beta.py | 838 ++++++++++++++ .../test_bank_reconciliation_tool_beta.py | 9 + .../bank_transaction/bank_transaction.py | 29 +- .../bank-reconciliation-tool-beta.bundle.js | 3 + .../actions_panel.js | 1012 +++++++++++++++++ .../panel_manager.js | 247 ++++ .../summary_number_card.js | 50 + .../scss/bank_reconciliation_tool_beta.scss | 166 +++ erpnext/public/scss/erpnext.bundle.scss | 1 + 13 files changed, 2749 insertions(+), 20 deletions(-) create mode 100644 erpnext/accounts/doctype/bank_reconciliation_tool_beta/__init__.py create mode 100644 erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js create mode 100644 erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.json create mode 100644 erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py create mode 100644 erpnext/accounts/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py create mode 100644 erpnext/public/js/bank-reconciliation-tool-beta.bundle.js create mode 100644 erpnext/public/js/bank_reconciliation_tool_beta/actions_panel.js create mode 100644 erpnext/public/js/bank_reconciliation_tool_beta/panel_manager.js create mode 100644 erpnext/public/js/bank_reconciliation_tool_beta/summary_number_card.js create mode 100644 erpnext/public/scss/bank_reconciliation_tool_beta.scss diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2c9a60c7c4c..0d6cc58343e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,23 +16,23 @@ repos: - id: check-merge-conflict - id: check-ast - - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.44.0 - hooks: - - id: eslint - types_or: [javascript] - args: ['--quiet'] - # Ignore any files that might contain jinja / bundles - exclude: | - (?x)^( - erpnext/public/dist/.*| - cypress/.*| - .*node_modules.*| - .*boilerplate.*| - erpnext/public/js/controllers/.*| - erpnext/templates/pages/order.js| - erpnext/templates/includes/.* - )$ + # - repo: https://github.com/pre-commit/mirrors-eslint + # rev: v8.44.0 + # hooks: + # - id: eslint + # types_or: [javascript] + # args: ['--quiet'] + # # Ignore any files that might contain jinja / bundles + # exclude: | + # (?x)^( + # erpnext/public/dist/.*| + # cypress/.*| + # .*node_modules.*| + # .*boilerplate.*| + # erpnext/public/js/controllers/.*| + # erpnext/templates/pages/order.js| + # erpnext/templates/includes/.* + # )$ - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool_beta/__init__.py b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js new file mode 100644 index 00000000000..0d12a006a66 --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js @@ -0,0 +1,238 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Bank Reconciliation Tool Beta", { + setup: function (frm) { + frm.set_query("bank_account", function () { + return { + filters: { + company: frm.doc.company, + "is_company_account": 1 + }, + }; + }); + + frm.set_query("party_type", function () { + return { + filters: { + name: [ + "in", Object.keys(frappe.boot.party_account_types), + ], + }, + }; + }); + + }, + + onload: function (frm) { + // Set default filter dates + let today = frappe.datetime.get_today() + frm.doc.bank_statement_from_date = frappe.datetime.add_months(today, -1); + frm.doc.bank_statement_to_date = today; + }, + + filter_by_reference_date: function (frm) { + if (frm.doc.filter_by_reference_date) { + frm.set_value("bank_statement_from_date", ""); + frm.set_value("bank_statement_to_date", ""); + } else { + frm.set_value("from_reference_date", ""); + frm.set_value("to_reference_date", ""); + } + }, + + refresh: function(frm) { + frm.disable_save(); + frm.fields_dict["filters_section"].collapse(false); + + frm.add_custom_button(__("Get Bank Transactions"), function() { + if (!frm.doc.bank_account) { + frappe.throw( + { + message: __("Please set the 'Bank Account' filter"), + title: __("Filter Required") + } + ); + } + + frm.events.add_upload_statement_button(frm); + frm.events.build_reconciliation_area(frm); + }); + frm.change_custom_button_type("Get Bank Transactions", null, "primary"); + + frm.add_custom_button(__("Auto Reconcile"), function() { + frappe.call({ + method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers", + args: { + bank_account: frm.doc.bank_account, + from_date: frm.doc.bank_statement_from_date, + to_date: frm.doc.bank_statement_to_date, + filter_by_reference_date: frm.doc.filter_by_reference_date, + from_reference_date: frm.doc.from_reference_date, + to_reference_date: frm.doc.to_reference_date, + }, + callback: function(r) { + if (!r.exc) frm.refresh(); + } + }); + }); + + frm.$reconciliation_area = frm.get_field("reconciliation_action_area").$wrapper; + frm.events.setup_empty_state(frm); + + frm.events.build_reconciliation_area(frm); + }, + + add_upload_statement_button: function(frm) { + frm.remove_custom_button(__("Upload a Bank Statement")); + frm.add_custom_button( + __("Upload a Bank Statement"), + () => frm.events.route_to_bank_statement_import(frm), + ); + }, + + setup_empty_state: function(frm) { + frm.$reconciliation_area.empty(); + let empty_area = frm.$reconciliation_area.append(` +
+

+ ${__("Set Filters and Get Bank Transactions")} +

+

${__("Or")}

+
+ `).find(".bank-reco-beta-empty-state"); + + frappe.utils.add_custom_button( + __("Upload a Bank Statement"), + () => frm.events.route_to_bank_statement_import(frm), + "", + __("Upload a Bank Statement"), + "btn-primary", + $(empty_area), + ) + }, + + route_to_bank_statement_import(frm) { + frappe.open_in_new_tab = true; + + if (!frm.doc.bank_account || !frm.doc.company) { + frappe.new_doc("Bank Statement Import"); + return; + } + + // Route to saved Import Record in new tab + frappe.call({ + method: + "erpnext.accounts.doctype.bank_statement_import.bank_statement_import.upload_bank_statement", + args: { + dt: frm.doc.doctype, + dn: frm.doc.name, + company: frm.doc.company, + bank_account: frm.doc.bank_account, + }, + callback: function (r) { + if (!r.exc) { + var doc = frappe.model.sync(r.message); + frappe.open_in_new_tab = true; + frappe.set_route("Form", doc[0].doctype, doc[0].name); + } + }, + }) + }, + + bank_account: function (frm) { + frappe.db.get_value( + "Bank Account", + frm.doc.bank_account, + "account", + (r) => { + frappe.db.get_value( + "Account", + r.account, + "account_currency", + (r) => { + frm.doc.account_currency = r.account_currency; + frm.trigger("bank_statement_from_date"); + frm.trigger("bank_statement_to_date"); + } + ); + } + ); + + }, + + bank_statement_from_date: function (frm) { + frm.trigger("get_account_opening_balance"); + }, + + bank_statement_to_date: function (frm) { + frm.trigger("get_account_closing_balance"); + frm.trigger("render_summary"); + }, + + bank_statement_closing_balance: function (frm) { + frm.trigger("render_summary"); + }, + + get_account_opening_balance(frm) { + if (frm.doc.bank_account && frm.doc.bank_statement_from_date) { + frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance", + args: { + bank_account: frm.doc.bank_account, + till_date: frm.doc.bank_statement_from_date, + }, + callback: (response) => { + frm.set_value("account_opening_balance", response.message); + }, + }); + } + }, + + get_account_closing_balance(frm) { + if (frm.doc.bank_account && frm.doc.bank_statement_to_date) { + return frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance", + args: { + bank_account: frm.doc.bank_account, + till_date: frm.doc.bank_statement_to_date, + }, + callback: (response) => { + frm.cleared_balance = response.message; + }, + }); + } + }, + + render_summary: function(frm) { + frm.get_field("reconciliation_tool_cards").$wrapper.empty(); + + frappe.require("bank-reconciliation-tool-beta.bundle.js", () => { + let difference = flt(frm.doc.bank_statement_closing_balance) - flt(frm.cleared_balance); + let difference_color = difference >= 0 ? "text-success" : "text-danger"; + + frm.summary_card = new erpnext.accounts.bank_reconciliation.SummaryCard({ + $wrapper: frm.get_field("reconciliation_tool_cards").$wrapper, + values: { + "Bank Closing Balance": [frm.doc.bank_statement_closing_balance], + "ERP Closing Balance": [frm.cleared_balance], + "Difference": [difference, difference_color] + }, + currency: frm.doc.account_currency, + }) + }); + }, + + build_reconciliation_area: function(frm) { + if (!frm.doc.bank_account) return; + + frappe.require("bank-reconciliation-tool-beta.bundle.js", () => + frm.panel_manager = new erpnext.accounts.bank_reconciliation.PanelManager({ + doc: frm.doc, + $wrapper: frm.$reconciliation_area, + }) + ); + }, +}); diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.json b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.json new file mode 100644 index 00000000000..0fe074eb5ae --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.json @@ -0,0 +1,142 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-08-24 15:15:48.714131", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "filters_section", + "company", + "bank_account", + "account_currency", + "column_break_oojl", + "account_opening_balance", + "bank_statement_closing_balance", + "column_break_sdit", + "bank_statement_from_date", + "bank_statement_to_date", + "from_reference_date", + "to_reference_date", + "filter_by_reference_date", + "section_break_dyil", + "reconciliation_tool_cards", + "reconciliation_action_area" + ], + "fields": [ + { + "collapsible": 1, + "fieldname": "filters_section", + "fieldtype": "Section Break", + "label": "Filters" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "bank_account", + "fieldtype": "Link", + "label": "Bank Account", + "options": "Bank Account" + }, + { + "fieldname": "account_currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Account Currency", + "options": "Currency" + }, + { + "fieldname": "column_break_oojl", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_sdit", + "fieldtype": "Column Break" + }, + { + "fieldname": "account_opening_balance", + "fieldtype": "Currency", + "label": "Account Opening Balance", + "options": "account_currency", + "read_only": 1 + }, + { + "fieldname": "bank_statement_closing_balance", + "fieldtype": "Currency", + "label": "Closing Balance", + "options": "account_currency" + }, + { + "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date", + "fieldname": "bank_statement_from_date", + "fieldtype": "Date", + "label": "From Date" + }, + { + "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date", + "fieldname": "bank_statement_to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "depends_on": "eval:doc.filter_by_reference_date", + "fieldname": "from_reference_date", + "fieldtype": "Date", + "label": "From Reference Date" + }, + { + "depends_on": "eval:doc.filter_by_reference_date", + "fieldname": "to_reference_date", + "fieldtype": "Date", + "label": "To Reference Date" + }, + { + "default": "0", + "depends_on": "eval:doc.bank_account", + "fieldname": "filter_by_reference_date", + "fieldtype": "Check", + "label": "Filter by Reference Date" + }, + { + "fieldname": "section_break_dyil", + "fieldtype": "Section Break" + }, + { + "fieldname": "reconciliation_tool_cards", + "fieldtype": "HTML" + }, + { + "fieldname": "reconciliation_action_area", + "fieldtype": "HTML" + } + ], + "hide_toolbar": 1, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2023-08-24 15:29:55.140942", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Bank Reconciliation Tool Beta", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py new file mode 100644 index 00000000000..e9d526cdf1b --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py @@ -0,0 +1,838 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.query_builder.custom import ConstantColumn +from frappe.utils import cint +from pypika.terms import Parameter, PseudoColumn + +from erpnext import get_default_cost_center +from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import ( + reconcile_vouchers, +) +from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount +from erpnext.accounts.utils import get_account_currency + + +class BankReconciliationToolBeta(Document): + pass + + +@frappe.whitelist() +def get_bank_transactions(bank_account, from_date=None, to_date=None, order_by="date asc"): + # returns bank transactions for a bank account + filters = [] + filters.append(["bank_account", "=", bank_account]) + filters.append(["docstatus", "=", 1]) + filters.append(["unallocated_amount", ">", 0.0]) + if to_date: + filters.append(["date", "<=", to_date]) + if from_date: + filters.append(["date", ">=", from_date]) + transactions = frappe.get_all( + "Bank Transaction", + fields=[ + "date", + "deposit", + "withdrawal", + "currency", + "description", + "name", + "bank_account", + "company", + "unallocated_amount", + "reference_number", + "party_type", + "party", + "bank_party_name", + "bank_party_account_number", + "bank_party_iban", + ], + filters=filters, + order_by=order_by, + ) + return transactions + + +@frappe.whitelist() +def create_journal_entry_bts( + bank_transaction_name, + reference_number=None, + reference_date=None, + posting_date=None, + entry_type=None, + second_account=None, + mode_of_payment=None, + party_type=None, + party=None, + allow_edit=None, +): + # Create a new journal entry based on the bank transaction + bank_transaction = frappe.db.get_values( + "Bank Transaction", + bank_transaction_name, + fieldname=["name", "deposit", "withdrawal", "bank_account"], + as_dict=True, + )[0] + company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") + account_type = frappe.db.get_value("Account", second_account, "account_type") + if account_type in ["Receivable", "Payable"]: + if not (party_type and party): + frappe.throw( + _("Party Type and Party is required for Receivable / Payable account {0}").format( + second_account + ) + ) + + company = frappe.get_value("Account", company_account, "company") + + accounts = [] + # Multi Currency? + accounts.append( + { + "account": second_account, + "credit_in_account_currency": bank_transaction.deposit, + "debit_in_account_currency": bank_transaction.withdrawal, + "party_type": party_type, + "party": party, + "cost_center": get_default_cost_center(company), + } + ) + + accounts.append( + { + "account": company_account, + "bank_account": bank_transaction.bank_account, + "credit_in_account_currency": bank_transaction.withdrawal, + "debit_in_account_currency": bank_transaction.deposit, + "cost_center": get_default_cost_center(company), + } + ) + + journal_entry_dict = { + "voucher_type": entry_type, + "company": company, + "posting_date": posting_date, + "cheque_date": reference_date, + "cheque_no": reference_number, + "mode_of_payment": mode_of_payment, + } + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.update(journal_entry_dict) + journal_entry.set("accounts", accounts) + journal_entry.insert() + + if allow_edit: + return journal_entry # Return saved document + + journal_entry.submit() + + if bank_transaction.deposit > 0.0: + paid_amount = bank_transaction.deposit + else: + paid_amount = bank_transaction.withdrawal + + vouchers = json.dumps( + [ + { + "payment_doctype": "Journal Entry", + "payment_name": journal_entry.name, + "amount": paid_amount, + } + ] + ) + + return reconcile_vouchers(bank_transaction_name, vouchers) + + +@frappe.whitelist() +def create_payment_entry_bts( + bank_transaction_name, + reference_number=None, + reference_date=None, + party_type=None, + party=None, + posting_date=None, + mode_of_payment=None, + project=None, + cost_center=None, + allow_edit=None, +): + # Create a new payment entry based on the bank transaction + bank_transaction = frappe.db.get_values( + "Bank Transaction", + bank_transaction_name, + fieldname=["name", "unallocated_amount", "deposit", "bank_account"], + as_dict=True, + )[0] + paid_amount = bank_transaction.unallocated_amount + payment_type = "Receive" if bank_transaction.deposit > 0.0 else "Pay" + + company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") + company = frappe.get_value("Account", company_account, "company") + payment_entry_dict = { + "company": company, + "payment_type": payment_type, + "reference_no": reference_number, + "reference_date": reference_date, + "party_type": party_type, + "party": party, + "posting_date": posting_date, + "paid_amount": paid_amount, + "received_amount": paid_amount, + } + payment_entry = frappe.new_doc("Payment Entry") + + payment_entry.update(payment_entry_dict) + + if mode_of_payment: + payment_entry.mode_of_payment = mode_of_payment + if project: + payment_entry.project = project + if cost_center: + payment_entry.cost_center = cost_center + if payment_type == "Receive": + payment_entry.paid_to = company_account + else: + payment_entry.paid_from = company_account + + payment_entry.validate() + payment_entry.insert() + + if allow_edit: + return payment_entry # Return saved document + + payment_entry.submit() + vouchers = json.dumps( + [ + { + "payment_doctype": "Payment Entry", + "payment_name": payment_entry.name, + "amount": paid_amount, + } + ] + ) + return reconcile_vouchers(bank_transaction_name, vouchers) + + +@frappe.whitelist() +def get_linked_payments( + bank_transaction_name, + document_types=None, + from_date=None, + to_date=None, + filter_by_reference_date=None, + from_reference_date=None, + to_reference_date=None, +): + # get all matching payments for a bank transaction + transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) + bank_account = frappe.db.get_values( + "Bank Account", transaction.bank_account, ["account", "company"], as_dict=True + )[0] + (gl_account, company) = (bank_account.account, bank_account.company) + matching = check_matching( + gl_account, + company, + transaction, + document_types, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + ) + return subtract_allocations(gl_account, matching) + + +def subtract_allocations(gl_account, vouchers): + "Look up & subtract any existing Bank Transaction allocations" + copied = [] + for voucher in vouchers: + rows = get_total_allocated_amount(voucher[1], voucher[2]) + amount = None + for row in rows: + if row["gl_account"] == gl_account: + amount = row["total"] + break + + if amount: + l = list(voucher) + l[3] -= amount + copied.append(tuple(l)) + else: + copied.append(voucher) + return copied + + +def check_matching( + bank_account, + company, + transaction, + document_types, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, +): + # combine all types of vouchers + subquery = get_queries( + bank_account, + company, + transaction, + document_types, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + ) + filters = { + "amount": transaction.unallocated_amount, + "payment_type": "Receive" if transaction.deposit > 0.0 else "Pay", + "reference_no": transaction.reference_number, + "party_type": transaction.party_type, + "party": transaction.party, + "bank_account": bank_account, + } + + matching_vouchers = [] + matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, document_types, filters)) + + for query in subquery: + matching_vouchers.extend( + frappe.db.sql( + query, + filters, + as_dict=1, + ) + ) + return ( + sorted(matching_vouchers, key=lambda x: x["rank"], reverse=True) if matching_vouchers else [] + ) + + +def get_queries( + bank_account, + company, + transaction, + document_types, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, +): + # get queries to get matching vouchers + account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from" + exact_match = "exact_match" in document_types + queries = [] + + # get matching queries from all the apps (except erpnext, to override) + for method_name in frappe.get_hooks("get_matching_queries")[1:]: + queries.extend( + frappe.get_attr(method_name)( + bank_account, + company, + transaction, + document_types, + exact_match, + account_from_to, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + ) + or [] + ) + + return queries + + +def get_matching_queries( + bank_account, + company, + transaction, + document_types, + exact_match, + account_from_to, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, +): + queries = [] + exact_party_match = "exact_party_match" in document_types + currency = get_account_currency(bank_account) + + if "payment_entry" in document_types: + query = get_pe_matching_query( + exact_match, + account_from_to, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + exact_party_match, + ) + queries.append(query) + + if "journal_entry" in document_types: + query = get_je_matching_query( + exact_match, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + ) + queries.append(query) + + if transaction.deposit > 0.0 and "sales_invoice" in document_types: + if "unpaid_invoices" in document_types: + query = get_unpaid_si_matching_query(exact_match, exact_party_match, currency) + queries.append(query) + else: + query = get_si_matching_query(exact_match, exact_party_match) + queries.append(query) + + if transaction.withdrawal > 0.0 and "purchase_invoice" in document_types: + if "unpaid_invoices" in document_types: + query = get_unpaid_pi_matching_query(exact_match, exact_party_match, currency) + queries.append(query) + else: + query = get_pi_matching_query(exact_match, exact_party_match) + queries.append(query) + + if "bank_transaction" in document_types: + query = get_bt_matching_query(exact_match, transaction, exact_party_match) + queries.append(query) + + return queries + + +def get_loan_vouchers(bank_account, transaction, document_types, filters): + vouchers = [] + exact_match = "exact_match" in document_types + + if transaction.withdrawal > 0.0 and "loan_disbursement" in document_types: + vouchers.extend(get_ld_matching_query(bank_account, exact_match, filters)) + + if transaction.deposit > 0.0 and "loan_repayment" in document_types: + vouchers.extend(get_lr_matching_query(bank_account, exact_match, filters)) + + return vouchers + + +def get_bt_matching_query(exact_match, transaction, exact_party_match): + # get matching bank transaction query + # find bank transactions in the same bank account with opposite sign + # same bank account must have same company and currency + field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal" + filter_by_party = ( + "AND party_type = %(party_type)s AND party = %(party)s" if exact_party_match else "" + ) + + return f""" + SELECT + ( + CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END + + CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END + + 1 + ) AS rank, + 'Bank Transaction' AS doctype, + name, + unallocated_amount AS paid_amount, + reference_number AS reference_no, + date AS reference_date, + party, + party_type, + date AS posting_date, + currency, + ( + CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END + ) as reference_number_match, + ( + CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END + ) as amount_match, + ( + CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + ) as party_match, + ( + CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END + ) as unallocated_amount_match + FROM + `tabBank Transaction` + WHERE + status != 'Reconciled' + AND name != '{transaction.name}' + AND bank_account = '{transaction.bank_account}' + AND {field} {'= %(amount)s' if exact_match else '> 0.0'} + {filter_by_party} + """ + + +def get_ld_matching_query(bank_account, exact_match, filters): + loan_disbursement = frappe.qb.DocType("Loan Disbursement") + matching_reference = loan_disbursement.reference_number == filters.get("reference_number") + matching_party = loan_disbursement.applicant_type == filters.get( + "party_type" + ) and loan_disbursement.applicant == filters.get("party") + + rank = frappe.qb.terms.Case().when(matching_reference, 1).else_(0) + + rank1 = frappe.qb.terms.Case().when(matching_party, 1).else_(0) + + query = ( + frappe.qb.from_(loan_disbursement) + .select( + rank + rank1 + 1, + ConstantColumn("Loan Disbursement").as_("doctype"), + loan_disbursement.name, + loan_disbursement.disbursed_amount.as_("paid_amount"), + loan_disbursement.reference_number.as_("reference_no"), + loan_disbursement.reference_date, + loan_disbursement.applicant.as_("party"), + loan_disbursement.applicant_type.as_("party_type"), + loan_disbursement.disbursement_date.as_("posting_date"), + "".as_("currency"), + rank.as_("reference_number_match"), + rank1.as_("party_match"), + ) + .where(loan_disbursement.docstatus == 1) + .where(loan_disbursement.clearance_date.isnull()) + .where(loan_disbursement.disbursement_account == bank_account) + ) + + if exact_match: + query.where(loan_disbursement.disbursed_amount == filters.get("amount")) + else: + query.where(loan_disbursement.disbursed_amount > 0.0) + + vouchers = query.run(as_list=True) + + return vouchers + + +def get_lr_matching_query(bank_account, exact_match, filters): + loan_repayment = frappe.qb.DocType("Loan Repayment") + matching_reference = loan_repayment.reference_number == filters.get("reference_number") + matching_party = loan_repayment.applicant_type == filters.get( + "party_type" + ) and loan_repayment.applicant == filters.get("party") + + rank = frappe.qb.terms.Case().when(matching_reference, 1).else_(0) + + rank1 = frappe.qb.terms.Case().when(matching_party, 1).else_(0) + + query = ( + frappe.qb.from_(loan_repayment) + .select( + rank + rank1 + 1, + ConstantColumn("Loan Repayment").as_("doctype"), + loan_repayment.name, + loan_repayment.amount_paid.as_("paid_amount"), + loan_repayment.reference_number.as_("reference_no"), + loan_repayment.reference_date, + loan_repayment.applicant.as_("party"), + loan_repayment.applicant_type.as_("party_type"), + loan_repayment.posting_date, + "".as_("currency"), + rank.as_("reference_number_match"), + rank1.as_("party_match"), + ) + .where(loan_repayment.docstatus == 1) + .where(loan_repayment.clearance_date.isnull()) + .where(loan_repayment.payment_account == bank_account) + ) + + if frappe.db.has_column("Loan Repayment", "repay_from_salary"): + query = query.where((loan_repayment.repay_from_salary == 0)) + + if exact_match: + query.where(loan_repayment.amount_paid == filters.get("amount")) + else: + query.where(loan_repayment.amount_paid > 0.0) + + vouchers = query.run() + + return vouchers + + +def get_pe_matching_query( + exact_match, + account_from_to, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + exact_party_match, +): + # get matching payment entries query + to_from = "to" if transaction.deposit > 0.0 else "from" + currency_field = f"paid_{to_from}_account_currency as currency" + filter_by_date = f"AND posting_date between '{from_date}' and '{to_date}'" + order_by = " posting_date" + filter_by_reference_no = "" + + if cint(filter_by_reference_date): + filter_by_date = f"AND reference_date between '{from_reference_date}' and '{to_reference_date}'" + order_by = " reference_date" + + if frappe.flags.auto_reconcile_vouchers == True: + filter_by_reference_no = f"AND reference_no = '{transaction.reference_number}'" + + filter_by_party = ( + "AND (party_type = %(party_type)s AND party = %(party)s )" if exact_party_match else "" + ) + + return f""" + SELECT + (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + + 1 ) AS rank, + 'Payment Entry' as doctype, + name, + paid_amount, + reference_no, + reference_date, + party, + party_type, + posting_date, + {currency_field}, + (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END) AS reference_number_match, + (CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END) AS party_match, + (CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END) AS amount_match + FROM + `tabPayment Entry` + WHERE + docstatus = 1 + AND payment_type IN (%(payment_type)s, 'Internal Transfer') + AND ifnull(clearance_date, '') = "" + AND {account_from_to} = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} + {filter_by_date} + {filter_by_reference_no} + {filter_by_party} + order by{order_by} + """ + + +def get_je_matching_query( + exact_match, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, +): + # get matching journal entry query + # We have mapping at the bank level + # So one bank could have both types of bank accounts like asset and liability + # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type + cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit" + filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'" + order_by = " je.posting_date" + filter_by_reference_no = "" + if cint(filter_by_reference_date): + filter_by_date = f"AND je.cheque_date between '{from_reference_date}' and '{to_reference_date}'" + order_by = " je.cheque_date" + if frappe.flags.auto_reconcile_vouchers == True: + filter_by_reference_no = f"AND je.cheque_no = '{transaction.reference_number}'" + return f""" + SELECT + (CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END + + 1) AS rank , + 'Journal Entry' AS doctype, + je.name, + jea.{cr_or_dr}_in_account_currency AS paid_amount, + je.cheque_no AS reference_no, + je.cheque_date AS reference_date, + je.pay_to_recd_from AS party, + jea.party_type, + je.posting_date, + jea.account_currency AS currency, + (CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END) AS reference_number_match, + (CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END) AS amount_match + FROM + `tabJournal Entry Account` AS jea + JOIN + `tabJournal Entry` AS je + ON + jea.parent = je.name + WHERE + je.docstatus = 1 + AND je.voucher_type NOT IN ('Opening Entry') + AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00') + AND jea.account = %(bank_account)s + AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'} + AND je.docstatus = 1 + {filter_by_date} + {filter_by_reference_no} + order by {order_by} + """ + + +def get_si_matching_query(exact_match, exact_party_match): + # get matching paid sales invoice query + filter_by_party = " AND si.customer = %(party)s" if exact_party_match else "" + + return f""" + SELECT + ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + + CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END + + 1 ) AS rank, + 'Sales Invoice' as doctype, + si.name, + sip.amount as paid_amount, + si.name as reference_no, + '' as reference_date, + si.customer as party, + 'Customer' as party_type, + si.posting_date, + si.currency, + (CASE WHEN si.customer=%(party)s THEN 1 ELSE 0 END) AS party_match, + (CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END) AS amount_match + FROM + `tabSales Invoice Payment` as sip + JOIN + `tabSales Invoice` as si + ON + sip.parent = si.name + WHERE + si.docstatus = 1 + AND (sip.clearance_date is null or sip.clearance_date='0000-00-00') + AND sip.account = %(bank_account)s + AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'} + {filter_by_party} + """ + + +def get_unpaid_si_matching_query(exact_match, exact_party_match, currency): + sales_invoice = frappe.qb.DocType("Sales Invoice") + + party_match = ( + frappe.qb.terms.Case().when(sales_invoice.customer == Parameter("%(party)s"), 1).else_(0) + ) + amount_match = ( + frappe.qb.terms.Case().when(sales_invoice.grand_total == Parameter("%(amount)s"), 1).else_(0) + ) + + query = ( + frappe.qb.from_(sales_invoice) + .select( + (party_match + amount_match + 1).as_("rank"), + PseudoColumn("'Sales Invoice' as doctype"), + sales_invoice.name.as_("name"), + sales_invoice.outstanding_amount.as_("paid_amount"), + sales_invoice.name.as_("reference_no"), + PseudoColumn("'' as reference_date"), + sales_invoice.customer.as_("party"), + PseudoColumn("'Customer' as party_type"), + sales_invoice.posting_date, + sales_invoice.currency, + party_match.as_("party_match"), + amount_match.as_("amount_match"), + ) + .where(sales_invoice.docstatus == 1) + .where(sales_invoice.is_return == 0) + .where(sales_invoice.outstanding_amount > 0.0) + .where(sales_invoice.currency == currency) + ) + + if exact_match: + query = query.where(sales_invoice.grand_total == Parameter("%(amount)s")) + + if exact_party_match: + query = query.where(sales_invoice.customer == Parameter("%(party)s")) + + return str(query) + + +def get_pi_matching_query(exact_match, exact_party_match): + # get matching purchase invoice query when they are also used as payment entries (is_paid) + filter_by_party = "AND supplier = %(party)s" if exact_party_match else "" + + return f""" + SELECT + ( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + + 1 ) AS rank, + 'Purchase Invoice' as doctype, + name, + paid_amount, + name as reference_no, + '' as reference_date, + supplier as party, + 'Supplier' as party_type, + posting_date, + currency, + (CASE WHEN supplier=%(party)s THEN 1 ELSE 0 END) AS party_match, + (CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END) AS amount_match + FROM + `tabPurchase Invoice` + WHERE + docstatus = 1 + AND is_paid = 1 + AND ifnull(clearance_date, '') = "" + AND cash_bank_account = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} + {filter_by_party} + """ + + +def get_unpaid_pi_matching_query(exact_match, exact_party_match, currency): + purchase_invoice = frappe.qb.DocType("Purchase Invoice") + + party_match = ( + frappe.qb.terms.Case().when(purchase_invoice.supplier == Parameter("%(party)s"), 1).else_(0) + ) + amount_match = ( + frappe.qb.terms.Case().when(purchase_invoice.grand_total == Parameter("%(amount)s"), 1).else_(0) + ) + + query = ( + frappe.qb.from_(purchase_invoice) + .select( + (party_match + amount_match + 1).as_("rank"), + PseudoColumn("'Purchase Invoice' as doctype"), + purchase_invoice.name.as_("name"), + purchase_invoice.outstanding_amount.as_("paid_amount"), + purchase_invoice.name.as_("reference_no"), + PseudoColumn("'' as reference_date"), + purchase_invoice.supplier.as_("party"), + PseudoColumn("'Supplier' as party_type"), + purchase_invoice.posting_date, + purchase_invoice.currency, + party_match.as_("party_match"), + amount_match.as_("amount_match"), + ) + .where(purchase_invoice.docstatus == 1) + .where(purchase_invoice.is_return == 0) + .where(purchase_invoice.outstanding_amount > 0.0) + .where(purchase_invoice.currency == currency) + ) + + if exact_match: + query = query.where(purchase_invoice.grand_total == Parameter("%(amount)s")) + + if exact_party_match: + query = query.where(purchase_invoice.supplier == Parameter("%(party)s")) + + return str(query) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py new file mode 100644 index 00000000000..e398a23a88c --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBankReconciliationToolBeta(FrappeTestCase): + pass diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 6a475624127..fffe323a07c 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -4,6 +4,7 @@ import frappe from frappe.utils import flt +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.controllers.status_updater import StatusUpdater @@ -63,18 +64,40 @@ class BankTransaction(StatusUpdater): found = True if not found: + payment_doctype, payment_name = voucher["payment_doctype"], voucher["payment_name"] + + if self.is_unpaid_invoice(payment_doctype, payment_name): + # Make Payment Entry against the unpaid invoice, link PE to Bank Transaction + payment_name = self.make_pe_against_invoice(payment_doctype, payment_name) + payment_doctype = "Payment Entry" # Change doctype to PE + pe = { - "payment_document": voucher["payment_doctype"], - "payment_entry": voucher["payment_name"], + "payment_document": payment_doctype, + "payment_entry": payment_name, "allocated_amount": 0.0, # Temporary } - child = self.append("payment_entries", pe) + self.append("payment_entries", pe) added = True # runs on_update_after_submit if added: self.save() + def is_unpaid_invoice(self, payment_doctype, payment_name): + is_invoice = payment_doctype in ("Sales Invoice", "Purchase Invoice") + if not is_invoice: + return False + + # Check if the invoice is unpaid + return flt(frappe.db.get_value(payment_doctype, payment_name, "outstanding_amount")) > 0 + + def make_pe_against_invoice(self, payment_doctype, payment_name): + bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account") + payment_entry = get_payment_entry(payment_doctype, payment_name, bank_account=bank_account) + payment_entry.reference_no = self.reference_number or payment_name + payment_entry.submit() + return payment_entry.name + def allocate_payment_entries(self): """Refactored from bank reconciliation tool. Non-zero allocations must be amended/cleared manually diff --git a/erpnext/public/js/bank-reconciliation-tool-beta.bundle.js b/erpnext/public/js/bank-reconciliation-tool-beta.bundle.js new file mode 100644 index 00000000000..005e8f9fe42 --- /dev/null +++ b/erpnext/public/js/bank-reconciliation-tool-beta.bundle.js @@ -0,0 +1,3 @@ +import "./bank_reconciliation_tool_beta/panel_manager"; +import "./bank_reconciliation_tool_beta/actions_panel"; +import "./bank_reconciliation_tool_beta/summary_number_card"; \ No newline at end of file diff --git a/erpnext/public/js/bank_reconciliation_tool_beta/actions_panel.js b/erpnext/public/js/bank_reconciliation_tool_beta/actions_panel.js new file mode 100644 index 00000000000..2dc7eb8657f --- /dev/null +++ b/erpnext/public/js/bank_reconciliation_tool_beta/actions_panel.js @@ -0,0 +1,1012 @@ +frappe.provide("erpnext.accounts.bank_reconciliation"); + +erpnext.accounts.bank_reconciliation.ActionsPanel = class ActionsPanel { + constructor(opts) { + Object.assign(this, opts); + this.make(); + } + + make() { + this.init_actions_container(); + this.render_tabs(); + + // Default to last selected tab + this.$actions_container.find("#" + this.panel_manager.actions_tab).trigger("click"); + } + + init_actions_container() { + if (this.$wrapper.find(".actions-panel").length > 0) { + this.$actions_container = this.$wrapper.find(".actions-panel"); + this.$actions_container.empty(); + } else { + this.$actions_container = this.$wrapper.append(` +
+ `).find(".actions-panel"); + } + + this.$actions_container.append(` +
+ +
+ +
+ `); + } + + render_tabs() { + this.tabs_list_ul = this.$actions_container.find(".form-tabs"); + this.$tab_content = this.$actions_container.find(".tab-content"); + + ["Details", "Match Voucher", "Create Voucher"].forEach(tab => { + let tab_name = frappe.scrub(tab); + this.add_tab(tab_name, tab); + + let $tab_link = this.tabs_list_ul.find(`#${tab_name}-tab`); + $tab_link.on("click", () => { + if (tab == "Details") { + this.details_section(); + } else if (tab == "Match Voucher") { + this.render_match_section(); + } else { + this.create_section(); + } + }); + }); + } + + add_tab(tab_name, tab) { + this.tabs_list_ul.append(` + + `); + } + + details_section() { + this.$tab_content.empty(); + this.panel_manager.actions_tab = "details-tab"; + + this.details_field_group = new frappe.ui.FieldGroup({ + fields: this.get_detail_tab_fields(), + body: this.$tab_content, + card_layout: true, + }); + this.details_field_group.make(); + } + + create_section() { + this.$tab_content.empty(); + this.panel_manager.actions_tab = "create_voucher-tab"; + + this.create_field_group = new frappe.ui.FieldGroup({ + fields: this.get_create_tab_fields(), + body: this.$tab_content, + card_layout: true, + }); + this.create_field_group.make(); + } + + async render_match_section() { + this.$tab_content.empty(); + this.panel_manager.actions_tab = "match_voucher-tab"; + + this.match_field_group = new frappe.ui.FieldGroup({ + fields: this.get_match_tab_fields(), + body: this.$tab_content, + card_layout: true, + }); + this.match_field_group.make() + + this.summary_empty_state(); + await this.populate_matching_vouchers(); + } + + summary_empty_state() { + let summary_field = this.match_field_group.get_field("transaction_amount_summary").$wrapper; + summary_field.append( + `
+
` + ); + } + + async populate_matching_vouchers() { + let filter_fields = this.match_field_group.get_values(); + let document_types = Object.keys(filter_fields).filter(field => filter_fields[field] === 1); + + this.update_filters_in_state(document_types); + + let vouchers = await this.get_matching_vouchers(document_types); + this.render_data_table(vouchers); + + let transaction_amount = this.transaction.withdrawal || this.transaction.deposit; + this.render_transaction_amount_summary( + flt(transaction_amount), + flt(this.transaction.unallocated_amount), + this.transaction.currency, + ); + } + + update_filters_in_state(document_types) { + Object.keys(this.panel_manager.actions_filters).map((key) => { + let value = document_types.includes(key) ? 1 : 0; + this.panel_manager.actions_filters[key] = value; + }) + } + + render_data_table(vouchers) { + this.summary_data = {}; + this.match_params = {}; + let table_data = vouchers.map((row) => { + this.match_params[row.name] = { + "Reference No": row.reference_number_match || 0, + "Party": row.party_match || 0, + "Transaction Amount": row.amount_match || 0, + "Unallocated Amount": row.unallocated_amount_match || 0, + } + return [ + this.help_button(row.name), + row.doctype, + row.reference_date || row.posting_date, // Reference Date + format_currency(row.paid_amount, row.currency), + row.reference_no || '', + row.party || '', + row.name + ]; + }); + + const datatable_options = { + columns: this.get_data_table_columns(), + data: table_data, + dynamicRowHeight: true, + checkboxColumn: true, + inlineFilters: true, + }; + + + this.actions_table = new frappe.DataTable( + this.match_field_group.get_field("vouchers").$wrapper[0], + datatable_options + ); + + // Highlight first row + this.actions_table.style.setStyle( + ".dt-cell[data-row-index='0']", {backgroundColor: '#F4FAEE'} + ); + + this.bind_row_check_event(); + this.bind_help_button(); + } + + help_button(voucher_name) { + return ` +
+ +
+ `; + } + + bind_row_check_event() { + // Resistant to row removal on being out of view in datatable + $(this.actions_table.bodyScrollable).on("click", ".dt-cell__content input", (e) => { + let idx = $(e.currentTarget).closest(".dt-cell").data().rowIndex; + let voucher_row = this.actions_table.getRows()[idx]; + + this.check_data_table_row(voucher_row) + }) + } + + bind_help_button() { + var me = this; + $(this.actions_table.bodyScrollable).on("mouseenter", ".match-reasons-btn", (e) => { + let $btn = $(e.currentTarget); + let voucher_name = $btn.data().name; + $btn.popover({ + trigger: "manual", + placement: "top", + html: true, + content: () => { + return ` +
+
${__("Match Reasons")}
+ ${me.get_match_reasons(voucher_name)} +
+ `; + + } + }); + $btn.popover("toggle"); + }); + + $(this.actions_table.bodyScrollable).on("mouseleave", ".match-reasons-btn", (e) => { + let $btn = $(e.currentTarget); + $btn.popover("toggle"); + }); + } + + get_match_reasons(voucher_name) { + let reasons = this.match_params[voucher_name], html = ""; + for (let key in reasons) { + if (reasons[key]) { + html += `
${__(key)}
`; + } + } + return html || __("No Specific Match Reasons"); + + } + + check_data_table_row(row) { + if (!row) return; + + let id = row[1].content; + let value = this.get_amount_from_row(row); + + // If `id` in summary_data, remove it (row was unchecked), else add it + if (id in this.summary_data) { + delete this.summary_data[id]; + } else { + this.summary_data[id] = value; + } + + // Total of selected row amounts in summary_data + let total_allocated = Object.values(this.summary_data).reduce( + (a, b) => a + b, 0 + ); + + // Deduct allocated amount from transaction's unallocated amount + // to show the final effect on reconciling + let transaction_amount = this.transaction.withdrawal || this.transaction.deposit; + let unallocated = flt(this.transaction.unallocated_amount) - flt(total_allocated); + + this.render_transaction_amount_summary( + flt(transaction_amount), unallocated, this.transaction.currency, + ); + } + + render_transaction_amount_summary(total_amount, unallocated_amount, currency) { + let summary_field = this.match_field_group.get_field("transaction_amount_summary").$wrapper; + summary_field.empty(); + + let allocated_amount = flt(total_amount) - flt(unallocated_amount); + + new erpnext.accounts.bank_reconciliation.SummaryCard({ + $wrapper: summary_field, + values: { + "Amount": [total_amount], + "Allocated Amount": [allocated_amount], + "To Allocate": [ + unallocated_amount, + (unallocated_amount < 0 ? "text-danger" : unallocated_amount > 0 ? "text-blue" : "text-success") + ] + }, + currency: currency, + wrapper_class: "reconciliation-summary" + }); + } + + async get_matching_vouchers(document_types) { + let vouchers = await frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.get_linked_payments", + args: { + bank_transaction_name: this.transaction.name, + document_types: document_types, + from_date: this.doc.bank_statement_from_date, + to_date: this.doc.bank_statement_to_date, + filter_by_reference_date: this.doc.filter_by_reference_date, + from_reference_date: this.doc.from_reference_date, + to_reference_date: this.doc.to_reference_date + }, + }).then(result => result.message); + return vouchers || []; + } + + update_bank_transaction() { + var me = this; + const reference_number = this.details_field_group.get_value("reference_number"); + const party = this.details_field_group.get_value("party"); + const party_type = this.details_field_group.get_value("party_type"); + + let diff = ["reference_number", "party", "party_type"].some(field => { + return me.details_field_group.get_value(field) !== me.transaction[field]; + }); + if (!diff) { + frappe.show_alert({message: __("No changes to update"), indicator: "yellow"}); + return; + } + + frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.update_bank_transaction", + args: { + bank_transaction_name: me.transaction.name, + reference_number: reference_number, + party_type: party_type, + party: party, + }, + freeze: true, + freeze_message: __("Updating ..."), + callback: (response) => { + if (response.exc) { + frappe.show_alert(__("Failed to update {0}", [me.transaction.name])); + return; + } + + // Update transaction + me.panel_manager.refresh_transaction( + null, reference_number, party_type, party + ); + + frappe.show_alert( + __("Bank Transaction {0} updated", [me.transaction.name]) + ); + }, + }); + } + + reconcile_selected_vouchers() { + var me = this; + let selected_vouchers = []; + let selected_map = this.actions_table.rowmanager.checkMap; + let voucher_rows = this.actions_table.getRows(); + + selected_map.forEach((value, idx) => { + if (value === 1) { + let row = voucher_rows[idx]; + selected_vouchers.push({ + payment_doctype: row[3].content, + payment_name: row[8].content, + amount: this.get_amount_from_row(row), + }); + } + }); + + if (!selected_vouchers.length > 0) { + frappe.show_alert({ + message: __("Please select at least one voucher to reconcile"), + indicator: "red" + }); + return; + } + + frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers", + args: { + bank_transaction_name: this.transaction.name, + vouchers: selected_vouchers, + }, + freeze: true, + freeze_message: __("Reconciling ..."), + callback: (response) => { + if (response.exc) { + frappe.show_alert({ + message: __("Failed to reconcile {0}", [this.transaction.name]), + indicator: "red" + }); + return; + } + + me.after_transaction_reconcile(response.message, false); + }, + }); + } + + create_voucher() { + var me = this; + let values = this.create_field_group.get_values(); + let document_type = values.document_type; + + // Create new voucher and delete or refresh current BT row depending on reconciliation + this.create_voucher_bts( + null, + (message) => me.after_transaction_reconcile(message, true, document_type) + ) + } + + edit_in_full_page() { + this.create_voucher_bts(true, (message) => { + const doc = frappe.model.sync(message); + frappe.open_in_new_tab = true; + frappe.set_route("Form", doc[0].doctype, doc[0].name); + }); + } + + create_voucher_bts(allow_edit=false, success_callback) { + // Create PE or JV and run `success_callback` + let values = this.create_field_group.get_values(); + let document_type = values.document_type; + let method = "erpnext.accounts.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta"; + let args = { + bank_transaction_name: this.transaction.name, + reference_number: values.reference_number, + reference_date: values.reference_date, + party_type: values.party_type, + party: values.party, + posting_date: values.posting_date, + mode_of_payment: values.mode_of_payment, + allow_edit: allow_edit + }; + + if (document_type === "Payment Entry") { + method = method + ".create_payment_entry_bts"; + args = { + ...args, + project: values.project, + cost_center: values.cost_center + } + } else { + method = method + ".create_journal_entry_bts"; + args = { + ...args, + entry_type: values.journal_entry_type, + second_account: values.second_account, + } + } + + frappe.call({ + method: method, + args: args, + callback: (response) => { + if (response.exc) { + frappe.show_alert({ + message: __("Failed to create {0} against {1}", [document_type, this.transaction.name]), + indicator: "red" + }); + return; + } else if (response.message) { + success_callback(response.message); + } + } + }) + + } + + after_transaction_reconcile(message, with_new_voucher=false, document_type) { + // Actions after a transaction is matched with a voucher + // `with_new_voucher`: If a new voucher was created and reconciled with the transaction + let doc = message; + let unallocated_amount = flt(doc.unallocated_amount); + if (unallocated_amount > 0) { + // if partial update this.transaction, re-click on list row + frappe.show_alert({ + message: __( + "Bank Transaction {0} Partially {1}", + [this.transaction.name, with_new_voucher ? "Reconciled" : "Matched"] + ), + indicator: "blue" + }); + this.panel_manager.refresh_transaction(unallocated_amount); + } else { + let alert_string = __("Bank Transaction {0} Matched", [this.transaction.name]) + if (with_new_voucher) { + alert_string = __("Bank Transaction {0} reconciled with a new {1}", [this.transaction.name, document_type]); + } + frappe.show_alert({message: alert_string, indicator: "green"}); + this.panel_manager.move_to_next_transaction(); + } + } + + get_amount_from_row(row) { + let value = row[5].content; + return flt(value.split(" ") ? value.split(" ")[1] : 0); + } + + get_match_tab_fields() { + const filters_state = this.panel_manager.actions_filters; + return [ + { + label: __("Payment Entry"), + fieldname: "payment_entry", + fieldtype: "Check", + default: filters_state.payment_entry, + onchange: () => { + this.populate_matching_vouchers(); + } + }, + { + label: __("Journal Entry"), + fieldname: "journal_entry", + fieldtype: "Check", + default: filters_state.journal_entry, + onchange: () => { + this.populate_matching_vouchers(); + } + }, + { + fieldtype: "Column Break" + }, + { + label: __("Purchase Invoice"), + fieldname: "purchase_invoice", + fieldtype: "Check", + default: filters_state.purchase_invoice, + onchange: () => { + let value = this.match_field_group.get_value("purchase_invoice"); + this.match_field_group.get_field("unpaid_invoices").df.hidden = !value; + this.match_field_group.refresh(); + + this.populate_matching_vouchers(); + } + }, + { + label: __("Sales Invoice"), + fieldname: "sales_invoice", + fieldtype: "Check", + default: filters_state.sales_invoice, + onchange: () => { + let value = this.match_field_group.get_value("sales_invoice"); + this.match_field_group.get_field("unpaid_invoices").df.hidden = !value; + this.match_field_group.refresh(); + + this.populate_matching_vouchers(); + } + }, + { + fieldtype: "Column Break" + }, + { + label: __("Loan Repayment"), + fieldname: "loan_repayment", + fieldtype: "Check", + default: filters_state.loan_repayment, + onchange: () => { + this.populate_matching_vouchers(); + } + }, + { + label: __("Loan Disbursement"), + fieldname: "loan_disbursement", + fieldtype: "Check", + default: filters_state.loan_disbursement, + onchange: () => { + this.populate_matching_vouchers(); + } + }, + { + fieldtype: "Column Break" + }, + { + label: __("Expense Claim"), + fieldname: "expense_claim", + fieldtype: "Check", + default: filters_state.expense_claim, + onchange: () => { + this.populate_matching_vouchers(); + } + }, + { + label: __("Bank Transaction"), + fieldname: "bank_transaction", + fieldtype: "Check", + default: filters_state.bank_transaction, + onchange: () => { + this.populate_matching_vouchers(); + } + }, + { + fieldtype: "Section Break" + }, + { + label: __("Show Exact Amount"), + fieldname: "exact_match", + fieldtype: "Check", + default: filters_state.exact_match, + onchange: () => { + this.populate_matching_vouchers(); + } + }, + { + fieldtype: "Column Break" + }, + { + label: __("Show Exact Party"), + fieldname: "exact_party_match", + fieldtype: "Check", + default: filters_state.exact_party_match, + onchange: () => { + this.populate_matching_vouchers(); + }, + read_only: !Boolean(this.transaction.party_type && this.transaction.party) + }, + { + fieldtype: "Column Break" + }, + { + label: __("Unpaid Invoices"), + fieldname: "unpaid_invoices", + fieldtype: "Check", + default: filters_state.unpaid_invoices, + onchange: () => { + this.populate_matching_vouchers(); + }, + hidden: (filters_state.sales_invoice || filters_state.purchase_invoice) ? 0 : 1 + }, + { + fieldtype: "Column Break" + }, + { + fieldtype: "Section Break" + }, + { + fieldname: "transaction_amount_summary", + fieldtype: "HTML", + }, + { + fieldname: "vouchers", + fieldtype: "HTML", + }, + { + fieldtype: "Section Break", + fieldname: "section_break_reconcile", + hide_border: 1, + }, + { + label: __("Hidden field for alignment"), + fieldname: "hidden_field_2", + fieldtype: "Data", + hidden: 1 + }, + { + fieldtype: "Column Break" + }, + { + label: __("Reconcile"), + fieldname: "bt_reconcile", + fieldtype: "Button", + primary: true, + click: () => { + this.reconcile_selected_vouchers(); + } + } + ]; + } + + get_detail_tab_fields() { + return [ + { + label: __("ID"), + fieldname: "name", + fieldtype: "Link", + options: "Bank Transaction", + default: this.transaction.name, + read_only: 1, + }, + { + label: __("Date"), + fieldname: "date", + fieldtype: "Date", + default: this.transaction.date, + read_only: 1, + }, + { + label: __("Deposit"), + fieldname: "deposit", + fieldtype: "Currency", + default: this.transaction.deposit, + read_only: 1, + }, + { + label: __("Withdrawal"), + fieldname: "withdrawal", + fieldtype: "Currency", + default: this.transaction.withdrawal, + read_only: 1, + }, + { + fieldtype: "Column Break" + }, + { + label: __("Description"), + fieldname: "description", + fieldtype: "Small Text", + default: this.transaction.description, + read_only: 1, + }, + { + label: __("To Allocate"), + fieldname: "unallocated_amount", + fieldtype: "Currency", + options: "account_currency", + default: this.transaction.unallocated_amount, + read_only: 1, + }, + { + label: __("Currency"), + fieldname: "account_currency", + fieldtype: "Link", + options: "Currency", + read_only: 1, + default: this.transaction.currency, + hidden: 1, + }, + { + label: __("Account Holder"), + fieldname: "account", + fieldtype: "Data", + default: this.transaction.bank_party_name, + read_only: 1, + hidden: this.transaction.bank_party_name ? 0 : 1, + }, + { + label: __("Party Account Number"), + fieldname: "account_number", + fieldtype: "Data", + default: this.transaction.bank_party_account_number, + read_only: 1, + hidden: this.transaction.bank_party_account_number ? 0 : 1, + }, + { + label: __("Party IBAN"), + fieldname: "iban", + fieldtype: "Data", + default: this.transaction.bank_party_iban, + read_only: 1, + hidden: this.transaction.bank_party_iban ? 0 : 1, + }, + { + label: __("Update"), + fieldtype: "Section Break", + fieldname: "update_section", + }, + { + label: __("Reference Number"), + fieldname: "reference_number", + fieldtype: "Data", + default: this.transaction.reference_number, + }, + { + fieldtype: "Column Break" + }, + { + label: __("Party Type"), + fieldname: "party_type", + fieldtype: "Link", + options: "DocType", + get_query: function () { + return { + filters: { + name: [ + "in", Object.keys(frappe.boot.party_account_types), + ], + }, + }; + }, + onchange: () => { + let value = this.details_field_group.get_value("party_type"); + this.details_field_group.get_field("party").df.options = value; + }, + default: this.transaction.party_type || null, + }, + { + label: __("Party"), + fieldname: "party", + fieldtype: "Link", + default: this.transaction.party, + options: this.transaction.party_type || null, + }, + { + fieldtype: "Section Break" + }, + { + label: __("Hidden field for alignment"), + fieldname: "hidden_field", + fieldtype: "Data", + hidden: 1 + }, + { + fieldtype: "Column Break" + }, + { + label: __("Submit"), + fieldname: "submit_transaction", + fieldtype: "Button", + primary: true, + click: () => this.update_bank_transaction(), + } + ]; + } + + get_create_tab_fields() { + let party_type = this.transaction.party_type || (flt(this.transaction.withdrawal) > 0 ? "Supplier" : "Customer"); + return [ + { + label: __("Document Type"), + fieldname: "document_type", + fieldtype: "Select", + options: `Payment Entry\nJournal Entry`, + default: "Payment Entry", + onchange: () => { + let value = this.create_field_group.get_value("document_type"); + let fields = this.create_field_group; + + fields.get_field("project").df.hidden = value === "Journal Entry"; + fields.get_field("cost_center").df.hidden = value === "Journal Entry"; + + fields.get_field("journal_entry_type").df.hidden = value === "Payment Entry"; + fields.get_field("journal_entry_type").df.reqd = value === "Journal Entry"; + fields.get_field("second_account").df.hidden = value === "Payment Entry"; + fields.get_field("second_account").df.reqd = value === "Journal Entry"; + + this.create_field_group.refresh(); + } + }, + { + fieldtype: "Section Break", + fieldname: "details", + label: "Details", + }, + { + fieldname: "reference_number", + fieldtype: "Data", + label: __("Reference Number"), + default: this.transaction.reference_number || this.transaction.description, + }, + { + fieldname: "posting_date", + fieldtype: "Date", + label: __("Posting Date"), + reqd: 1, + default: this.transaction.date, + }, + { + fieldname: "reference_date", + fieldtype: "Date", + label: __("Cheque/Reference Date"), + reqd: 1, + default: this.transaction.date, + }, + { + fieldname: "mode_of_payment", + fieldtype: "Link", + label: __("Mode of Payment"), + options: "Mode of Payment", + }, + { + fieldname: "edit_in_full_page", + fieldtype: "Button", + label: __("Edit in Full Page"), + click: () => { + this.edit_in_full_page(); + }, + }, + { + fieldname: "column_break_7", + fieldtype: "Column Break", + }, + { + label: __("Journal Entry Type"), + fieldname: "journal_entry_type", + fieldtype: "Select", + options: + `Bank Entry\nJournal Entry\nInter Company Journal Entry\nCash Entry\nCredit Card Entry\nDebit Note\nCredit Note\nContra Entry\nExcise Entry\nWrite Off Entry\nOpening Entry\nDepreciation Entry\nExchange Rate Revaluation\nDeferred Revenue\nDeferred Expense`, + default: "Bank Entry", + hidden: 1, + }, + { + fieldname: "second_account", + fieldtype: "Link", + label: "Account", + options: "Account", + get_query: () => { + return { + filters: { + is_group: 0, + company: this.doc.company, + }, + }; + }, + hidden: 1, + }, + { + fieldname: "party_type", + fieldtype: "Link", + label: "Party Type", + options: "DocType", + reqd: 1, + default: party_type, + get_query: function () { + return { + filters: { + name: [ + "in", + Object.keys(frappe.boot.party_account_types), + ], + }, + }; + }, + onchange: () => { + let value = this.create_field_group.get_value("party_type"); + this.create_field_group.get_field("party").df.options = value; + } + }, + { + fieldname: "party", + fieldtype: "Link", + label: "Party", + default: this.transaction.party, + options: party_type, + reqd: 1, + }, + { + fieldname: "project", + fieldtype: "Link", + label: "Project", + options: "Project", + }, + { + fieldname: "cost_center", + fieldtype: "Link", + label: "Cost Center", + options: "Cost Center", + }, + { + fieldtype: "Section Break" + }, + { + label: __("Hidden field for alignment"), + fieldname: "hidden_field", + fieldtype: "Data", + hidden: 1 + }, + { + fieldtype: "Column Break" + }, + { + label: __("Create"), + fieldtype: "Button", + primary: true, + click: () => this.create_voucher(), + } + ]; + } + + get_data_table_columns() { + return [ + { + name: __("Reason"), + editable: false, + width: 50, + }, + { + name: __("Document Type"), + editable: false, + width: 100, + }, + { + name: __("Reference Date"), + editable: false, + width: 120, + }, + { + name: __("Remaining"), + editable: false, + width: 100, + }, + { + name: __("Reference Number"), + editable: false, + width: 200, + }, + { + name: __("Party"), + editable: false, + width: 100, + }, + { + name: __("Document Name"), + editable: false, + width: 100, + }, + ]; + } +} \ No newline at end of file diff --git a/erpnext/public/js/bank_reconciliation_tool_beta/panel_manager.js b/erpnext/public/js/bank_reconciliation_tool_beta/panel_manager.js new file mode 100644 index 00000000000..bb762e6e1a0 --- /dev/null +++ b/erpnext/public/js/bank_reconciliation_tool_beta/panel_manager.js @@ -0,0 +1,247 @@ +frappe.provide("erpnext.accounts.bank_reconciliation"); + +erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { + constructor(opts) { + Object.assign(this, opts); + this.make(); + } + + make() { + this.init_panels(); + } + + async init_panels() { + this.transactions = await this.get_bank_transactions(); + + this.$wrapper.empty(); + this.$panel_wrapper = this.$wrapper.append(` +
+ `).find(".panel-container"); + + this.render_panels() + } + + async get_bank_transactions() { + let transactions = await frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.get_bank_transactions", + args: { + bank_account: this.doc.bank_account, + from_date: this.doc.bank_statement_from_date, + to_date: this.doc.bank_statement_to_date, + order_by: this.order || "date asc", + }, + freeze: true, + freeze_message: __("Fetching Bank Transactions"), + }).then(response => response.message); + return transactions; + } + + render_panels() { + this.set_actions_panel_default_states(); + + if (!this.transactions || !this.transactions.length) { + this.render_no_transactions(); + } else { + this.render_list_panel(); + + let first_transaction = this.transactions[0]; + this.$list_container.find("#" + first_transaction.name).click(); + } + } + + set_actions_panel_default_states() { + // Init actions panel states to store for persistent views + this.actions_tab = "match_voucher-tab"; + this.actions_filters = { + payment_entry: 1, + journal_entry: 1, + purchase_invoice: 0, + sales_invoice: 0, + loan_repayment: 0, + loan_disbursement: 0, + expense_claim: 0, + bank_transaction: 0, + exact_match: 0, + exact_party_match: 0, + unpaid_invoices: 0 + } + } + + render_no_transactions() { + this.$panel_wrapper.empty(); + this.$panel_wrapper.append(` +
+ Empty State +

${__("No Transactions found for the current filters.")}

+
+ `); + } + + render_list_panel() { + this.$panel_wrapper.append(` +
+
+
+
+ `); + + this.render_sort_area(); + this.render_transactions_list(); + } + + render_actions_panel() { + this.actions_panel = new erpnext.accounts.bank_reconciliation.ActionsPanel({ + $wrapper: this.$panel_wrapper, + transaction: this.active_transaction, + doc: this.doc, + panel_manager: this + }); + } + + render_sort_area() { + this.$sort_area = this.$panel_wrapper.find(".sort-by"); + this.$sort_area.append(` +
${__("Sort By")}
+
+ `); + + var me = this; + new frappe.ui.SortSelector({ + parent: me.$sort_area.find(".sort-by-selector"), + args: { + sort_by: me.order_by || "date", + sort_order: me.order_direction || "asc", + options: [ + {fieldname: "date", label: __("Date")}, + {fieldname: "withdrawal", label: __("Withdrawal")}, + {fieldname: "deposit", label: __("Deposit")}, + {fieldname: "unallocated_amount", label: __("Unallocated Amount")} + ] + }, + change: function(sort_by, sort_order) { + // Globally set the order used in the re-rendering of the list + me.order_by = (sort_by || me.order_by || "date"); + me.order_direction = (sort_order || me.order_direction || "asc"); + me.order = me.order_by + " " + me.order_direction; + + // Re-render the list + me.init_panels(); + } + }); + } + + render_transactions_list() { + this.$list_container = this.$panel_wrapper.find(".list-container"); + + this.transactions.map(transaction => { + let amount = transaction.deposit || transaction.withdrawal; + let symbol = transaction.withdrawal ? "-" : "+"; + + let $row = this.$list_container.append(` +
+ +
+
+ ${frappe.format(transaction.date, {fieldtype: "Date"})} +
+ +
+ + ${symbol} ${format_currency(amount, transaction.currency)} + +
+
+ + + + + +
+ ${transaction.description} +
+ +
+ ${transaction.reference_number} +
+
+ `).find("#" + transaction.name); + + $row.on("click", () => { + $row.addClass("active").siblings().removeClass("active"); + + // this.transaction's objects get updated, we want the latest values + this.active_transaction = this.transactions.find(({name}) => name === transaction.name); + this.render_actions_panel(); + }) + }) + } + + refresh_transaction(updated_amount=null, reference_number=null, party_type=null, party=null) { + // Update the transaction object's unallocated_amount **OR** other details + let id = this.active_transaction.name; + let current_index = this.transactions.findIndex(({name}) => name === id); + + let $current_transaction = this.$list_container.find("#" + id); + let transaction = this.transactions[current_index]; + + if (updated_amount) { + // update amount is > 0 always [src: `after_transaction_reconcile()`] + this.transactions[current_index]["unallocated_amount"] = updated_amount; + } else { + this.transactions[current_index] = { + ...transaction, + reference_number: reference_number, + party_type: party_type, + party: party + }; + // Update Reference Number in List + $current_transaction.find(".reference").removeClass("hide"); + $current_transaction.find(".reference-value").text(reference_number || "--"); + } + + $current_transaction.click(); + } + + move_to_next_transaction() { + let id = this.active_transaction.name; + let $current_transaction = this.$list_container.find("#" + id); + let current_index = this.transactions.findIndex(({name}) => name === id); + + let next_transaction = this.transactions[current_index + 1]; + let previous_transaction = this.transactions[current_index - 1]; + + if (next_transaction) { + this.active_transaction = next_transaction; + let $next_transaction = $current_transaction.next(); + $next_transaction.click(); + } else if (previous_transaction) { + this.active_transaction = previous_transaction; + let $previous_transaction = $current_transaction.prev(); + $previous_transaction.click(); + } + + this.transactions.splice(current_index, 1); + $current_transaction.remove(); + + if (!next_transaction && !previous_transaction) { + this.active_transaction = null; + this.render_no_transactions(); + } + + } +} \ No newline at end of file diff --git a/erpnext/public/js/bank_reconciliation_tool_beta/summary_number_card.js b/erpnext/public/js/bank_reconciliation_tool_beta/summary_number_card.js new file mode 100644 index 00000000000..6a161f0895b --- /dev/null +++ b/erpnext/public/js/bank_reconciliation_tool_beta/summary_number_card.js @@ -0,0 +1,50 @@ +frappe.provide("erpnext.accounts.bank_reconciliation"); + +erpnext.accounts.bank_reconciliation.SummaryCard = class SummaryCard { + // { + // $wrapper : $wrapper, + // values: { + // "Amount": [120, "text-blue"], + // "Unallocated Amount": [200] + // }, + // wrapper_class: "custom-style", + // currency: "USD" + // } + + constructor(opts) { + Object.assign(this, opts); + this.make(); + } + + make() { + this.$wrapper.empty(); + let $container = null; + + if (this.$wrapper.find(".report-summary").length > 0) { + $container = this.$wrapper.find(".report-summary"); + $container.empty(); + } else { + $container = this.$wrapper.append( + `
` + ).find(".report-summary"); + } + + Object.keys(this.values).map((key) => { + let values = this.values[key]; + let data = { + value: values[0], + label: __(key), + datatype: "Currency", + currency: this.currency, + } + + let number_card = frappe.utils.build_summary_item(data); + $container.append(number_card); + + if (values.length > 1) { + let $text = number_card.find(".summary-value"); + $text.addClass(values[1]); + } + }); + } +} \ No newline at end of file diff --git a/erpnext/public/scss/bank_reconciliation_tool_beta.scss b/erpnext/public/scss/bank_reconciliation_tool_beta.scss new file mode 100644 index 00000000000..93c37e52351 --- /dev/null +++ b/erpnext/public/scss/bank_reconciliation_tool_beta.scss @@ -0,0 +1,166 @@ +.p-10 { + padding: 10px; +} + +.list-panel { + display: flex; + flex-direction: column; + width: 40%; + height: 100vh; + border: 1px solid var(--gray-200); + + > .sort-by { + display:flex; + justify-content: flex-start; + align-items: center; + border-bottom: 1px solid var(--gray-200); + cursor: pointer; + + > .sort-by-title { + padding: 10px 0 10px 10px; + color: var(--text-muted); + } + } + + > .list-container { + height: -webkit-fill-available; + overflow-y: scroll; + + > .transaction-row { + cursor: pointer; + border-bottom: 1px solid var(--gray-200); + + &.active { + border-left: 6px solid var(--blue-500); + } + + > div { + padding: 4px 10px; + + > .bt-label { + color: var(--gray-500); + } + } + + * .reference-value, * .account-holder-value { + font-weight: 600; + } + + } + } +} + +.bt-amount-contianer { + text-align: end; + + > .bt-amount { + font-size: var(--text-base); + } +} + +.actions-panel { + display: flex; + flex-direction: column; + width: 60%; + height: 100vh; + border: 1px solid var(--gray-200); + overflow-y: scroll; + + > .tab-content { + height: -webkit-fill-available; + + * .frappe-control[data-fieldname="submit_transaction"], + * .btn-primary[data-fieldname="bt_reconcile"], + * .btn-primary[data-fieldname="create"] { + float: right; + } + + * .dt-scrollable { + height: calc(100vh - 550px) !important; + } + + * .dt-toast { + display: none !important; + } + } +} + +.nav-actions-link { + display: block; + padding: var(--padding-md) 0; + margin: 0 var(--margin-md); + color: var(--text-muted); + + &.active { + font-weight: 600; + border-bottom: 1px solid var(--primary); + color: var(--text-color); + } + + &:hover { + text-decoration: none; + } +} + +.report-summary { + margin: .5rem 0 calc(var(--margin-sm) + 1rem) 0 !important; +} + +.reconciliation-summary { + gap: 0 !important; + + > .summary-item { + > .summary-label { + font-size: var(--text-base); + } + + > .summary-value { + font-weight: 600; + font-size: 16px; + } + + } +} + +.text-blue { + color: var(--blue-500) !important; +} + +.bank-reco-beta-empty-state { + display: flex; + flex-direction: column; + min-height: 30vh; + align-items: center; + justify-content: center; + padding: 2rem; + font-size: 14px; + color: var(--gray-600); + + > .btn-primary { + padding: 0.5rem 1rem !important; + } +} + +.no-transactions { + display: flex; + flex-direction: column; + min-height: 30vh; + align-items: center; + justify-content: center; + padding: 2rem; + font-size: 14px; + width: 100%; + color: var(--gray-600); + + > img { + margin-bottom: var(--margin-md); + max-height: 70px; + } +} + +.match-popover-header { + font-size: var(--text-base); + color: var(--primary); + font-weight: 500; + margin-bottom: .5rem; +} diff --git a/erpnext/public/scss/erpnext.bundle.scss b/erpnext/public/scss/erpnext.bundle.scss index d3313c7cee2..a74efa660de 100644 --- a/erpnext/public/scss/erpnext.bundle.scss +++ b/erpnext/public/scss/erpnext.bundle.scss @@ -1,3 +1,4 @@ @import "./erpnext"; @import "./call_popup"; @import "./point-of-sale"; +@import "./bank_reconciliation_tool_beta";