From 6de5367f12dd31111cb6b2bd9c144a3c2d764d2a Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Sat, 9 May 2026 23:14:58 +0530 Subject: [PATCH] feat: new banking module (#54720) * feat: initial SPA setup for banking * wip: bring over new banking module * feat: added Espresso design tokens * feat: button styles * fix: add all ink colors * wip: espresso design system changes * feat: button and badge espresso components * fix: button styling for reconcile * feat: Espresso progress bar * feat: Espresso toggle switch * feat: Espresso tabs design * fix: vertical tab support * fix: button sizing across modals * feat: Espresso style table layout * feat: Espresso tooltip * feat: Espresso elevations and checkbox * feat: Dialog with Espresso styles * feat: Espresso textarea * fix: input styles * fix: colors on bank picker * fix: breadcrumb styling * fix: bank picker styling * feat: create doctypes and fields for bank reconciliation * feat: APIs for banking * fix: use date format parser * fix: font styling to match Espresso * wip: settings modal * feat: settings dialog component * fix: icons and invalid requests * feat: preferences tab * fix: adjust icon stroke width to 1.5 * feat: rule configuration in settings * fix: remove sheet component * feat: alert and error banner component * feat: dropdown in Espresso * feat: popover and select in Espresso * fix: cleanup more styles * fix: match size of link fields * feat: command styling * fix: remove unused style tokens * fix: styles for global date picker dropdown * fix: styles for match and reconcile * feat: table Espresso component * feat: remove all other design tokens * fix: remove unused tokens * fix: form elements * fix: remove unused styles and fix filters in bank transaction list * feat: fetch bank rec doctypes for filtering * fix: record payment modal * feat: support for dark mode switching * fix: move bank logos to public folder * feat: add support for RTL * feat: support for RTL * chore: send layout direction in dev boot * fix: make checkbox work in RTL * feat: dark mode support * fix: dark mode style * feat: bank logos in dark mode * feat: dark mode bank logos * chore: use dark mode bank logos everywhere * chore: move rule evaluation to controller * chore: add tests for bank transaction rules * fix: move deps to fix actions errors * fix: move tw-animate-css to deps * fix: remove shadcn * fix: do not open modal if no transactions selected * fix: add translation strings * feat: add banner on existing bank reconciliation tool * feat: bank statement import * fix: translations and layout directions * fix: validation for transaction matching rule * fix: styles * fix: show conflicting transactions in alert * fix: show help text for new banking module forms * feat: show total debits and credits * fix: dark mode colors in automatic config * feat: add keyboard shortcuts help * feat: added keyboard shortcut for settings * fix: decrease size of progress bar * chore: bump packages * feat: add tests for statement import * fix: settings dialog * fix: show banner on small screens * fix: show banner when no bank account set --- .gitignore | 4 + babel_extractors.csv | 2 + banking/.env.production | 1 + banking/.gitignore | 24 + banking/README.md | 73 + banking/eslint.config.js | 24 + banking/index.html | 50 + banking/package.json | 66 + banking/proxyOptions.ts | 13 + banking/src/App.tsx | 65 + .../components/common/AccountsDropdown.tsx | 228 + banking/src/components/common/BankLogo.tsx | 26 + .../components/common/FileUploadBanner.tsx | 17 + .../components/common/LinkFieldCombobox.tsx | 301 + .../components/common/PartyTypeDropdown.tsx | 82 + .../features/ActionLog/ActionLog.tsx | 475 + .../BankReconciliation/BankBalance.tsx | 334 + .../BankClearanceSummary.tsx | 355 + .../BankReconciliation/BankEntryModal.tsx | 831 ++ .../BankReconciliation/BankPicker.tsx | 124 + .../BankReconciliation/BankRecDateFilter.tsx | 275 + .../BankReconciliationStatement.tsx | 315 + .../BankTransactionList.tsx | 419 + .../BankTransactionUnreconcileModal.tsx | 125 + .../BankReconciliation/CompanySelector.tsx | 92 + .../IncorrectlyClearedEntries.tsx | 229 + .../BankReconciliation/MatchAndReconcile.tsx | 949 ++ .../BankReconciliation/MatchFilters.tsx | 93 + .../MissingFiltersBanner.tsx | 10 + .../BankReconciliation/RecordPaymentModal.tsx | 1301 +++ .../Rules/CreateNewRule.tsx | 89 + .../BankReconciliation/Rules/EditRule.tsx | 101 + .../BankReconciliation/Rules/RuleForm.tsx | 799 ++ .../SelectedTransactionDetails.tsx | 73 + .../SelectedTransactionsTable.tsx | 47 + .../BankReconciliation/TransferModal.tsx | 555 + .../BankReconciliation/bankRecAtoms.ts | 83 + .../features/BankReconciliation/logos.ts | 397 + .../features/BankReconciliation/utils.ts | 457 + .../BankStatementImporter/CSV/CSVImport.tsx | 22 + .../CSV/CSVRawDataPreview.tsx | 151 + .../CSV/StatementDetails.tsx | 351 + .../BankStatementImporter/import_utils.ts | 42 + .../features/Settings/KeyboardShortcuts.tsx | 115 + .../features/Settings/MatchingRules.tsx | 46 + .../features/Settings/Preferences.tsx | 261 + .../features/Settings/Rules/RuleList.tsx | 314 + .../components/features/Settings/Settings.tsx | 95 + banking/src/components/ui/alert-dialog.tsx | 196 + banking/src/components/ui/alert.tsx | 104 + banking/src/components/ui/badge.tsx | 188 + banking/src/components/ui/breadcrumb.tsx | 109 + banking/src/components/ui/button.tsx | 263 + banking/src/components/ui/calendar.tsx | 218 + banking/src/components/ui/card.tsx | 92 + banking/src/components/ui/checkbox.tsx | 44 + banking/src/components/ui/command.tsx | 183 + banking/src/components/ui/dialog.tsx | 156 + banking/src/components/ui/direction.tsx | 20 + banking/src/components/ui/dropdown-menu.tsx | 262 + banking/src/components/ui/empty.tsx | 85 + banking/src/components/ui/error-banner.tsx | 64 + banking/src/components/ui/file-dropzone.tsx | 289 + banking/src/components/ui/form-elements.tsx | 383 + banking/src/components/ui/form.tsx | 174 + banking/src/components/ui/hover-card.tsx | 42 + banking/src/components/ui/input-group.tsx | 161 + banking/src/components/ui/input.tsx | 49 + banking/src/components/ui/kbd.tsx | 28 + banking/src/components/ui/keyboard-keys.tsx | 8 + banking/src/components/ui/label.tsx | 22 + banking/src/components/ui/list-view.tsx | 510 + banking/src/components/ui/loaders.tsx | 27 + banking/src/components/ui/markdown.tsx | 28 + banking/src/components/ui/popover.tsx | 87 + banking/src/components/ui/progress.tsx | 67 + banking/src/components/ui/radio-group.tsx | 43 + banking/src/components/ui/select.tsx | 221 + banking/src/components/ui/separator.tsx | 26 + banking/src/components/ui/settings-dialog.tsx | 273 + banking/src/components/ui/skeleton.tsx | 13 + banking/src/components/ui/sonner.tsx | 53 + banking/src/components/ui/stats.tsx | 13 + banking/src/components/ui/switch.tsx | 45 + banking/src/components/ui/table.tsx | 114 + banking/src/components/ui/tabs.tsx | 168 + banking/src/components/ui/textarea.tsx | 43 + banking/src/components/ui/theme-provider.tsx | 89 + banking/src/components/ui/tooltip.tsx | 56 + banking/src/components/ui/typography.tsx | 45 + banking/src/hooks/use-mobile.ts | 19 + banking/src/hooks/useCurrentCompany.ts | 9 + banking/src/hooks/useDocType.ts | 30 + banking/src/hooks/useFiscalYear.ts | 13 + .../src/hooks/usePaymentEntryCalculations.tsx | 138 + banking/src/index.css | 1208 ++ banking/src/lib/checks.ts | 20 + banking/src/lib/company.ts | 15 + banking/src/lib/currency.ts | 24 + banking/src/lib/date.ts | 184 + banking/src/lib/file.ts | 68 + banking/src/lib/frappe.ts | 85 + banking/src/lib/namespace/defaults.js | 12 + banking/src/lib/namespace/index.js | 3 + banking/src/lib/namespace/namespace.js | 22 + banking/src/lib/namespace/sync.js | 187 + banking/src/lib/numbers.ts | 249 + banking/src/lib/permissions.ts | 78 + banking/src/lib/translate.ts | 45 + banking/src/lib/utils.ts | 18 + banking/src/main.tsx | 42 + banking/src/pages/BankReconciliation.tsx | 140 + banking/src/pages/BankStatementImporter.tsx | 255 + .../pages/BankStatementImporterContainer.tsx | 37 + .../src/pages/ViewBankStatementImportLog.tsx | 46 + .../src/types/Accounts/AccountsSettings.ts | 134 + .../types/Accounts/AdvanceTaxesandCharges.ts | 41 + banking/src/types/Accounts/BankAccount.ts | 49 + .../src/types/Accounts/BankAccountBalance.ts | 19 + .../types/Accounts/BankStatementImportLog.ts | 50 + .../BankStatementImportLogColumnMap.ts | 21 + banking/src/types/Accounts/BankTransaction.ts | 64 + .../types/Accounts/BankTransactionPayments.ts | 23 + .../src/types/Accounts/BankTransactionRule.ts | 43 + .../Accounts/BankTransactionRuleAccounts.ts | 25 + ...ankTransactionRuleDescriptionConditions.ts | 17 + banking/src/types/Accounts/JournalEntry.ts | 96 + .../src/types/Accounts/JournalEntryAccount.ts | 53 + banking/src/types/Accounts/PaymentEntry.ts | 148 + .../types/Accounts/PaymentEntryDeduction.ts | 23 + .../types/Accounts/PaymentEntryReference.ts | 47 + banking/src/types/custom/Reports.ts | 20 + banking/src/vite-env.d.ts | 6 + banking/tsconfig.app.json | 34 + banking/tsconfig.json | 19 + banking/tsconfig.node.json | 28 + banking/vite.config.ts | 25 + banking/yarn.lock | 3796 +++++++ .../accounts_settings/accounts_settings.json | 17 + .../accounts_settings/accounts_settings.py | 2 + .../doctype/bank_account/bank_account.json | 10 +- .../doctype/bank_account/bank_account.py | 89 + .../doctype/bank_account_balance/__init__.py | 0 .../bank_account_balance.js | 8 + .../bank_account_balance.json | 96 + .../bank_account_balance.py | 22 + .../test_bank_account_balance.py | 20 + .../bank_reconciliation_tool.js | 5 + .../bank_reconciliation_tool.py | 594 +- .../bank_statement_import_log/__init__.py | 0 .../bank_statement_import_log.js | 12 + .../bank_statement_import_log.json | 211 + .../bank_statement_import_log.py | 743 ++ .../test_bank_statement_import_log.py | 349 + .../__init__.py | 0 .../bank_statement_import_log_column_map.json | 60 + .../bank_statement_import_log_column_map.py | 42 + .../bank_transaction/bank_transaction.json | 20 +- .../bank_transaction/bank_transaction.py | 61 +- .../bank_transaction_payments.json | 16 +- .../bank_transaction_payments.py | 1 + .../doctype/bank_transaction_rule/__init__.py | 0 .../bank_transaction_rule.js | 20 + .../bank_transaction_rule.json | 194 + .../bank_transaction_rule.py | 249 + .../test_bank_transaction_rule.py | 231 + .../__init__.py | 0 .../bank_transaction_rule_accounts.json | 68 + .../bank_transaction_rule_accounts.py | 28 + .../__init__.py | 0 ...ansaction_rule_description_conditions.json | 44 + ...transaction_rule_description_conditions.py | 24 + erpnext/hooks.py | 2 + erpnext/public/images/bank-logos/ABSA.png | Bin 0 -> 13854 bytes erpnext/public/images/bank-logos/ANZ.png | Bin 0 -> 9644 bytes .../images/bank-logos/Airwallex-dark.png | Bin 0 -> 10130 bytes .../public/images/bank-logos/Airwallex.png | Bin 0 -> 10460 bytes .../public/images/bank-logos/Alpha_Bank.svg | 17 + erpnext/public/images/bank-logos/Amex.svg | Bin 0 -> 60376 bytes .../bank-logos/Australian_Tax_Office.png | Bin 0 -> 9066 bytes .../public/images/bank-logos/Avanz-dark.svg | 43 + erpnext/public/images/bank-logos/Avanz.svg | 43 + .../public/images/bank-logos/Axis_Bank.svg | 105 + .../images/bank-logos/BAC_Credomatic.svg | 17 + .../images/bank-logos/BNP_Paribas-Dark.svg | 124 + .../public/images/bank-logos/BNP_Paribas.svg | 124 + .../images/bank-logos/BNY_Mellon-Dark.svg | 18 + .../public/images/bank-logos/BNY_Mellon.svg | 18 + .../images/bank-logos/Banco_Atlantida.png | Bin 0 -> 4800 bytes .../public/images/bank-logos/Banco_Lafise.png | Bin 0 -> 33550 bytes .../images/bank-logos/Banco_de_Finanzas.svg | 1 + .../images/bank-logos/Bank_of_America.png | Bin 0 -> 39122 bytes .../images/bank-logos/Bank_of_Baroda.svg | 101 + .../images/bank-logos/Bank_of_India.png | Bin 0 -> 121911 bytes .../images/bank-logos/Bank_of_Maharashtra.png | Bin 0 -> 45473 bytes erpnext/public/images/bank-logos/Barclays.svg | 63 + .../public/images/bank-logos/Capital_One.png | Bin 0 -> 19135 bytes .../images/bank-logos/Charles_Schwab.svg | 1 + erpnext/public/images/bank-logos/Citi.svg | 11 + .../images/bank-logos/Commonwealth_Bank.svg | 1 + .../images/bank-logos/Deutsche_Bank.svg | 125 + .../images/bank-logos/Diamond_Trust_Bank.png | Bin 0 -> 2144 bytes .../images/bank-logos/Equity_Bank-dark.png | Bin 0 -> 11380 bytes .../public/images/bank-logos/Equity_Bank.png | Bin 0 -> 12473 bytes .../images/bank-logos/Federal_Bank-Dark.png | Bin 0 -> 75120 bytes .../public/images/bank-logos/Federal_Bank.png | Bin 0 -> 79299 bytes erpnext/public/images/bank-logos/Fi_Bank.svg | 1 + erpnext/public/images/bank-logos/Ficohsa.svg | 13 + .../images/bank-logos/Goldman_Sachs.svg | 3 + erpnext/public/images/bank-logos/HDFC.svg | 37 + .../public/images/bank-logos/HSBC-dark.svg | 21 + erpnext/public/images/bank-logos/HSBC.svg | 21 + erpnext/public/images/bank-logos/I&M.png | Bin 0 -> 164234 bytes .../public/images/bank-logos/ICICI-dark.svg | 567 + erpnext/public/images/bank-logos/ICICI.svg | 567 + .../public/images/bank-logos/IDBI_Bank.svg | 1 + .../images/bank-logos/IDFC_First_Bank.svg | 26 + .../images/bank-logos/IndusInd_Bank.svg | 83 + .../images/bank-logos/Judo_Bank-dark.svg | 1 + .../public/images/bank-logos/Judo_Bank.svg | 1 + .../images/bank-logos/KCB_Bank_Kenya.png | Bin 0 -> 20736 bytes .../images/bank-logos/Kotak_Mahindra.svg | 1 + .../public/images/bank-logos/Macquarie.svg | 1 + .../images/bank-logos/Morgan_Stanley.png | Bin 0 -> 10729 bytes .../images/bank-logos/Oakstar-dark.webp | Bin 0 -> 44796 bytes erpnext/public/images/bank-logos/Oakstar.png | Bin 0 -> 9557 bytes erpnext/public/images/bank-logos/PNC.png | Bin 0 -> 13923 bytes .../images/bank-logos/PlainsCapitalBank.png | Bin 0 -> 32099 bytes .../public/images/bank-logos/Prime_Bank.png | Bin 0 -> 5257 bytes .../bank-logos/Punjab_National_Bank.svg | 1 + .../images/bank-logos/RBL_Bank-dark.svg | 17 + erpnext/public/images/bank-logos/RBL_Bank.svg | 9704 +++++++++++++++++ .../images/bank-logos/Razorpay-dark.svg | 21 + erpnext/public/images/bank-logos/Razorpay.svg | 21 + erpnext/public/images/bank-logos/Revolut.png | Bin 0 -> 4039 bytes .../public/images/bank-logos/Santander.svg | 12 + .../public/images/bank-logos/Sparkasse.png | Bin 0 -> 36141 bytes erpnext/public/images/bank-logos/Stanbic.png | Bin 0 -> 70873 bytes .../bank-logos/Standard_Chartered-dark.png | Bin 0 -> 38100 bytes .../images/bank-logos/Standard_Chartered.png | Bin 0 -> 54760 bytes .../images/bank-logos/Starling_Bank-dark.png | Bin 0 -> 13680 bytes .../images/bank-logos/Starling_Bank.png | Bin 0 -> 13452 bytes .../images/bank-logos/State_Bank_of_India.svg | 86 + .../bank-logos/State_bank_of_India-Dark.svg | 86 + .../bank-logos/Toronto_Dominion_Bank.png | Bin 0 -> 5802 bytes erpnext/public/images/bank-logos/Truist.svg | 13 + erpnext/public/images/bank-logos/UBS-dark.svg | 1 + erpnext/public/images/bank-logos/UBS.svg | 1 + .../public/images/bank-logos/USBank-dark.svg | 1 + erpnext/public/images/bank-logos/USBank.svg | 1 + .../images/bank-logos/Union_Bank_of_India.svg | 156 + .../Volksbanken_Raiffeisenbanken.svg | 33 + .../public/images/bank-logos/Wells_Fargo.svg | 44 + erpnext/public/images/bank-logos/Westpac.svg | 1 + .../images/bank-logos/Yes_Bank-dark.svg | 4 + erpnext/public/images/bank-logos/Yes_Bank.svg | 4 + .../public/images/bank-logos/chase-Dark.svg | 28 + erpnext/public/images/bank-logos/chase.svg | 28 + erpnext/public/images/bank-logos/jpmc.svg | 68 + erpnext/www/banking.py | 54 + package.json | 7 +- pyproject.toml | 5 + 262 files changed, 39467 insertions(+), 14 deletions(-) create mode 100644 banking/.env.production create mode 100644 banking/.gitignore create mode 100644 banking/README.md create mode 100644 banking/eslint.config.js create mode 100644 banking/index.html create mode 100644 banking/package.json create mode 100644 banking/proxyOptions.ts create mode 100644 banking/src/App.tsx create mode 100644 banking/src/components/common/AccountsDropdown.tsx create mode 100644 banking/src/components/common/BankLogo.tsx create mode 100644 banking/src/components/common/FileUploadBanner.tsx create mode 100644 banking/src/components/common/LinkFieldCombobox.tsx create mode 100644 banking/src/components/common/PartyTypeDropdown.tsx create mode 100644 banking/src/components/features/ActionLog/ActionLog.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankBalance.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankEntryModal.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankPicker.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankRecDateFilter.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankTransactionList.tsx create mode 100644 banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx create mode 100644 banking/src/components/features/BankReconciliation/CompanySelector.tsx create mode 100644 banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx create mode 100644 banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx create mode 100644 banking/src/components/features/BankReconciliation/MatchFilters.tsx create mode 100644 banking/src/components/features/BankReconciliation/MissingFiltersBanner.tsx create mode 100644 banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx create mode 100644 banking/src/components/features/BankReconciliation/Rules/CreateNewRule.tsx create mode 100644 banking/src/components/features/BankReconciliation/Rules/EditRule.tsx create mode 100644 banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx create mode 100644 banking/src/components/features/BankReconciliation/SelectedTransactionDetails.tsx create mode 100644 banking/src/components/features/BankReconciliation/SelectedTransactionsTable.tsx create mode 100644 banking/src/components/features/BankReconciliation/TransferModal.tsx create mode 100644 banking/src/components/features/BankReconciliation/bankRecAtoms.ts create mode 100644 banking/src/components/features/BankReconciliation/logos.ts create mode 100644 banking/src/components/features/BankReconciliation/utils.ts create mode 100644 banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx create mode 100644 banking/src/components/features/BankStatementImporter/CSV/CSVRawDataPreview.tsx create mode 100644 banking/src/components/features/BankStatementImporter/CSV/StatementDetails.tsx create mode 100644 banking/src/components/features/BankStatementImporter/import_utils.ts create mode 100644 banking/src/components/features/Settings/KeyboardShortcuts.tsx create mode 100644 banking/src/components/features/Settings/MatchingRules.tsx create mode 100644 banking/src/components/features/Settings/Preferences.tsx create mode 100644 banking/src/components/features/Settings/Rules/RuleList.tsx create mode 100644 banking/src/components/features/Settings/Settings.tsx create mode 100644 banking/src/components/ui/alert-dialog.tsx create mode 100644 banking/src/components/ui/alert.tsx create mode 100644 banking/src/components/ui/badge.tsx create mode 100644 banking/src/components/ui/breadcrumb.tsx create mode 100644 banking/src/components/ui/button.tsx create mode 100644 banking/src/components/ui/calendar.tsx create mode 100644 banking/src/components/ui/card.tsx create mode 100644 banking/src/components/ui/checkbox.tsx create mode 100644 banking/src/components/ui/command.tsx create mode 100644 banking/src/components/ui/dialog.tsx create mode 100644 banking/src/components/ui/direction.tsx create mode 100644 banking/src/components/ui/dropdown-menu.tsx create mode 100644 banking/src/components/ui/empty.tsx create mode 100644 banking/src/components/ui/error-banner.tsx create mode 100644 banking/src/components/ui/file-dropzone.tsx create mode 100644 banking/src/components/ui/form-elements.tsx create mode 100644 banking/src/components/ui/form.tsx create mode 100644 banking/src/components/ui/hover-card.tsx create mode 100644 banking/src/components/ui/input-group.tsx create mode 100644 banking/src/components/ui/input.tsx create mode 100644 banking/src/components/ui/kbd.tsx create mode 100644 banking/src/components/ui/keyboard-keys.tsx create mode 100644 banking/src/components/ui/label.tsx create mode 100644 banking/src/components/ui/list-view.tsx create mode 100644 banking/src/components/ui/loaders.tsx create mode 100644 banking/src/components/ui/markdown.tsx create mode 100644 banking/src/components/ui/popover.tsx create mode 100644 banking/src/components/ui/progress.tsx create mode 100644 banking/src/components/ui/radio-group.tsx create mode 100644 banking/src/components/ui/select.tsx create mode 100644 banking/src/components/ui/separator.tsx create mode 100644 banking/src/components/ui/settings-dialog.tsx create mode 100644 banking/src/components/ui/skeleton.tsx create mode 100644 banking/src/components/ui/sonner.tsx create mode 100644 banking/src/components/ui/stats.tsx create mode 100644 banking/src/components/ui/switch.tsx create mode 100644 banking/src/components/ui/table.tsx create mode 100644 banking/src/components/ui/tabs.tsx create mode 100644 banking/src/components/ui/textarea.tsx create mode 100644 banking/src/components/ui/theme-provider.tsx create mode 100644 banking/src/components/ui/tooltip.tsx create mode 100644 banking/src/components/ui/typography.tsx create mode 100644 banking/src/hooks/use-mobile.ts create mode 100644 banking/src/hooks/useCurrentCompany.ts create mode 100644 banking/src/hooks/useDocType.ts create mode 100644 banking/src/hooks/useFiscalYear.ts create mode 100644 banking/src/hooks/usePaymentEntryCalculations.tsx create mode 100644 banking/src/index.css create mode 100644 banking/src/lib/checks.ts create mode 100644 banking/src/lib/company.ts create mode 100644 banking/src/lib/currency.ts create mode 100644 banking/src/lib/date.ts create mode 100644 banking/src/lib/file.ts create mode 100644 banking/src/lib/frappe.ts create mode 100644 banking/src/lib/namespace/defaults.js create mode 100644 banking/src/lib/namespace/index.js create mode 100644 banking/src/lib/namespace/namespace.js create mode 100644 banking/src/lib/namespace/sync.js create mode 100644 banking/src/lib/numbers.ts create mode 100644 banking/src/lib/permissions.ts create mode 100644 banking/src/lib/translate.ts create mode 100644 banking/src/lib/utils.ts create mode 100644 banking/src/main.tsx create mode 100644 banking/src/pages/BankReconciliation.tsx create mode 100644 banking/src/pages/BankStatementImporter.tsx create mode 100644 banking/src/pages/BankStatementImporterContainer.tsx create mode 100644 banking/src/pages/ViewBankStatementImportLog.tsx create mode 100644 banking/src/types/Accounts/AccountsSettings.ts create mode 100644 banking/src/types/Accounts/AdvanceTaxesandCharges.ts create mode 100644 banking/src/types/Accounts/BankAccount.ts create mode 100644 banking/src/types/Accounts/BankAccountBalance.ts create mode 100644 banking/src/types/Accounts/BankStatementImportLog.ts create mode 100644 banking/src/types/Accounts/BankStatementImportLogColumnMap.ts create mode 100644 banking/src/types/Accounts/BankTransaction.ts create mode 100644 banking/src/types/Accounts/BankTransactionPayments.ts create mode 100644 banking/src/types/Accounts/BankTransactionRule.ts create mode 100644 banking/src/types/Accounts/BankTransactionRuleAccounts.ts create mode 100644 banking/src/types/Accounts/BankTransactionRuleDescriptionConditions.ts create mode 100644 banking/src/types/Accounts/JournalEntry.ts create mode 100644 banking/src/types/Accounts/JournalEntryAccount.ts create mode 100644 banking/src/types/Accounts/PaymentEntry.ts create mode 100644 banking/src/types/Accounts/PaymentEntryDeduction.ts create mode 100644 banking/src/types/Accounts/PaymentEntryReference.ts create mode 100644 banking/src/types/custom/Reports.ts create mode 100644 banking/src/vite-env.d.ts create mode 100644 banking/tsconfig.app.json create mode 100644 banking/tsconfig.json create mode 100644 banking/tsconfig.node.json create mode 100644 banking/vite.config.ts create mode 100644 banking/yarn.lock create mode 100644 erpnext/accounts/doctype/bank_account_balance/__init__.py create mode 100644 erpnext/accounts/doctype/bank_account_balance/bank_account_balance.js create mode 100644 erpnext/accounts/doctype/bank_account_balance/bank_account_balance.json create mode 100644 erpnext/accounts/doctype/bank_account_balance/bank_account_balance.py create mode 100644 erpnext/accounts/doctype/bank_account_balance/test_bank_account_balance.py create mode 100644 erpnext/accounts/doctype/bank_statement_import_log/__init__.py create mode 100644 erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.js create mode 100644 erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.json create mode 100644 erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py create mode 100644 erpnext/accounts/doctype/bank_statement_import_log/test_bank_statement_import_log.py create mode 100644 erpnext/accounts/doctype/bank_statement_import_log_column_map/__init__.py create mode 100644 erpnext/accounts/doctype/bank_statement_import_log_column_map/bank_statement_import_log_column_map.json create mode 100644 erpnext/accounts/doctype/bank_statement_import_log_column_map/bank_statement_import_log_column_map.py create mode 100644 erpnext/accounts/doctype/bank_transaction_rule/__init__.py create mode 100644 erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.js create mode 100644 erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.json create mode 100644 erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.py create mode 100644 erpnext/accounts/doctype/bank_transaction_rule/test_bank_transaction_rule.py create mode 100644 erpnext/accounts/doctype/bank_transaction_rule_accounts/__init__.py create mode 100644 erpnext/accounts/doctype/bank_transaction_rule_accounts/bank_transaction_rule_accounts.json create mode 100644 erpnext/accounts/doctype/bank_transaction_rule_accounts/bank_transaction_rule_accounts.py create mode 100644 erpnext/accounts/doctype/bank_transaction_rule_description_conditions/__init__.py create mode 100644 erpnext/accounts/doctype/bank_transaction_rule_description_conditions/bank_transaction_rule_description_conditions.json create mode 100644 erpnext/accounts/doctype/bank_transaction_rule_description_conditions/bank_transaction_rule_description_conditions.py create mode 100644 erpnext/public/images/bank-logos/ABSA.png create mode 100644 erpnext/public/images/bank-logos/ANZ.png create mode 100644 erpnext/public/images/bank-logos/Airwallex-dark.png create mode 100644 erpnext/public/images/bank-logos/Airwallex.png create mode 100644 erpnext/public/images/bank-logos/Alpha_Bank.svg create mode 100644 erpnext/public/images/bank-logos/Amex.svg create mode 100644 erpnext/public/images/bank-logos/Australian_Tax_Office.png create mode 100644 erpnext/public/images/bank-logos/Avanz-dark.svg create mode 100644 erpnext/public/images/bank-logos/Avanz.svg create mode 100644 erpnext/public/images/bank-logos/Axis_Bank.svg create mode 100644 erpnext/public/images/bank-logos/BAC_Credomatic.svg create mode 100644 erpnext/public/images/bank-logos/BNP_Paribas-Dark.svg create mode 100644 erpnext/public/images/bank-logos/BNP_Paribas.svg create mode 100644 erpnext/public/images/bank-logos/BNY_Mellon-Dark.svg create mode 100644 erpnext/public/images/bank-logos/BNY_Mellon.svg create mode 100644 erpnext/public/images/bank-logos/Banco_Atlantida.png create mode 100644 erpnext/public/images/bank-logos/Banco_Lafise.png create mode 100644 erpnext/public/images/bank-logos/Banco_de_Finanzas.svg create mode 100644 erpnext/public/images/bank-logos/Bank_of_America.png create mode 100644 erpnext/public/images/bank-logos/Bank_of_Baroda.svg create mode 100644 erpnext/public/images/bank-logos/Bank_of_India.png create mode 100644 erpnext/public/images/bank-logos/Bank_of_Maharashtra.png create mode 100644 erpnext/public/images/bank-logos/Barclays.svg create mode 100644 erpnext/public/images/bank-logos/Capital_One.png create mode 100644 erpnext/public/images/bank-logos/Charles_Schwab.svg create mode 100644 erpnext/public/images/bank-logos/Citi.svg create mode 100644 erpnext/public/images/bank-logos/Commonwealth_Bank.svg create mode 100644 erpnext/public/images/bank-logos/Deutsche_Bank.svg create mode 100644 erpnext/public/images/bank-logos/Diamond_Trust_Bank.png create mode 100644 erpnext/public/images/bank-logos/Equity_Bank-dark.png create mode 100644 erpnext/public/images/bank-logos/Equity_Bank.png create mode 100644 erpnext/public/images/bank-logos/Federal_Bank-Dark.png create mode 100644 erpnext/public/images/bank-logos/Federal_Bank.png create mode 100644 erpnext/public/images/bank-logos/Fi_Bank.svg create mode 100644 erpnext/public/images/bank-logos/Ficohsa.svg create mode 100644 erpnext/public/images/bank-logos/Goldman_Sachs.svg create mode 100644 erpnext/public/images/bank-logos/HDFC.svg create mode 100644 erpnext/public/images/bank-logos/HSBC-dark.svg create mode 100644 erpnext/public/images/bank-logos/HSBC.svg create mode 100644 erpnext/public/images/bank-logos/I&M.png create mode 100644 erpnext/public/images/bank-logos/ICICI-dark.svg create mode 100644 erpnext/public/images/bank-logos/ICICI.svg create mode 100644 erpnext/public/images/bank-logos/IDBI_Bank.svg create mode 100644 erpnext/public/images/bank-logos/IDFC_First_Bank.svg create mode 100644 erpnext/public/images/bank-logos/IndusInd_Bank.svg create mode 100644 erpnext/public/images/bank-logos/Judo_Bank-dark.svg create mode 100644 erpnext/public/images/bank-logos/Judo_Bank.svg create mode 100644 erpnext/public/images/bank-logos/KCB_Bank_Kenya.png create mode 100644 erpnext/public/images/bank-logos/Kotak_Mahindra.svg create mode 100644 erpnext/public/images/bank-logos/Macquarie.svg create mode 100644 erpnext/public/images/bank-logos/Morgan_Stanley.png create mode 100644 erpnext/public/images/bank-logos/Oakstar-dark.webp create mode 100644 erpnext/public/images/bank-logos/Oakstar.png create mode 100644 erpnext/public/images/bank-logos/PNC.png create mode 100644 erpnext/public/images/bank-logos/PlainsCapitalBank.png create mode 100644 erpnext/public/images/bank-logos/Prime_Bank.png create mode 100644 erpnext/public/images/bank-logos/Punjab_National_Bank.svg create mode 100644 erpnext/public/images/bank-logos/RBL_Bank-dark.svg create mode 100644 erpnext/public/images/bank-logos/RBL_Bank.svg create mode 100644 erpnext/public/images/bank-logos/Razorpay-dark.svg create mode 100644 erpnext/public/images/bank-logos/Razorpay.svg create mode 100644 erpnext/public/images/bank-logos/Revolut.png create mode 100644 erpnext/public/images/bank-logos/Santander.svg create mode 100644 erpnext/public/images/bank-logos/Sparkasse.png create mode 100644 erpnext/public/images/bank-logos/Stanbic.png create mode 100644 erpnext/public/images/bank-logos/Standard_Chartered-dark.png create mode 100644 erpnext/public/images/bank-logos/Standard_Chartered.png create mode 100644 erpnext/public/images/bank-logos/Starling_Bank-dark.png create mode 100644 erpnext/public/images/bank-logos/Starling_Bank.png create mode 100644 erpnext/public/images/bank-logos/State_Bank_of_India.svg create mode 100644 erpnext/public/images/bank-logos/State_bank_of_India-Dark.svg create mode 100644 erpnext/public/images/bank-logos/Toronto_Dominion_Bank.png create mode 100644 erpnext/public/images/bank-logos/Truist.svg create mode 100644 erpnext/public/images/bank-logos/UBS-dark.svg create mode 100644 erpnext/public/images/bank-logos/UBS.svg create mode 100644 erpnext/public/images/bank-logos/USBank-dark.svg create mode 100644 erpnext/public/images/bank-logos/USBank.svg create mode 100644 erpnext/public/images/bank-logos/Union_Bank_of_India.svg create mode 100644 erpnext/public/images/bank-logos/Volksbanken_Raiffeisenbanken.svg create mode 100644 erpnext/public/images/bank-logos/Wells_Fargo.svg create mode 100644 erpnext/public/images/bank-logos/Westpac.svg create mode 100644 erpnext/public/images/bank-logos/Yes_Bank-dark.svg create mode 100644 erpnext/public/images/bank-logos/Yes_Bank.svg create mode 100644 erpnext/public/images/bank-logos/chase-Dark.svg create mode 100644 erpnext/public/images/bank-logos/chase.svg create mode 100644 erpnext/public/images/bank-logos/jpmc.svg create mode 100644 erpnext/www/banking.py diff --git a/.gitignore b/.gitignore index f9f70d0c643..217091c99b7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ node_modules/ .backportrc.json # Aider AI Chat .aider* + +# Banking SPA +erpnext/public/banking +erpnext/www/banking.html \ No newline at end of file diff --git a/babel_extractors.csv b/babel_extractors.csv index 4c9f885d911..98e6a32c14e 100644 --- a/babel_extractors.csv +++ b/babel_extractors.csv @@ -1,3 +1,5 @@ **/setup/setup_wizard/data/uom_data.json,erpnext.gettext.extractors.uom_data.extract **/setup/doctype/incoterm/incoterms.csv,erpnext.gettext.extractors.incoterms.extract **/setup/setup_wizard/data/*.txt,erpnext.gettext.extractors.lines_from_txt_file.extract +**.tsx,frappe.gettext.extractors.html_template.extract +**.ts,frappe.gettext.extractors.html_template.extract diff --git a/banking/.env.production b/banking/.env.production new file mode 100644 index 00000000000..e6f44cb7ce0 --- /dev/null +++ b/banking/.env.production @@ -0,0 +1 @@ +VITE_BASE_NAME="banking" \ No newline at end of file diff --git a/banking/.gitignore b/banking/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/banking/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/banking/README.md b/banking/README.md new file mode 100644 index 00000000000..d2e77611fd3 --- /dev/null +++ b/banking/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/banking/eslint.config.js b/banking/eslint.config.js new file mode 100644 index 00000000000..9cc2a204656 --- /dev/null +++ b/banking/eslint.config.js @@ -0,0 +1,24 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + onlyExportComponents: false, + }, +]); diff --git a/banking/index.html b/banking/index.html new file mode 100644 index 00000000000..0f30097886a --- /dev/null +++ b/banking/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + Banking | {{ app_name }} + + + +
+ + + + + \ No newline at end of file diff --git a/banking/package.json b/banking/package.json new file mode 100644 index 00000000000..984c364bb80 --- /dev/null +++ b/banking/package.json @@ -0,0 +1,66 @@ +{ + "name": "banking", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build --base=/assets/erpnext/banking/ && yarn copy-html-entry", + "lint": "eslint .", + "preview": "vite preview", + "copy-html-entry": "cp ../erpnext/public/banking/index.html ../erpnext/www/banking.html" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", + "@vitejs/plugin-react": "^6.0.1", + "chrono-node": "^2.9.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "dayjs": "^1.11.20", + "frappe-react-sdk": "^1.14.0", + "fuse.js": "^7.3.0", + "jotai": "^2.20.0", + "jotai-family": "^1.0.1", + "lodash.isplainobject": "^4.0.6", + "lucide-react": "^1.14.0", + "radix-ui": "^1.4.3", + "react": "^19.2.6", + "react-currency-input-field": "^4.0.5", + "react-day-picker": "9.14.0", + "react-dom": "^19.2.6", + "react-dropzone": "^15.0.0", + "react-hook-form": "^7.75.0", + "react-hotkeys-hook": "^5.3.2", + "react-markdown": "^10.1.0", + "react-router": "^7.15.0", + "react-router-dom": "^7.15.0", + "react-virtuoso": "^4.18.6", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.3.0", + "tw-animate-css": "^1.4.0", + "usehooks-ts": "^3.1.1", + "vite": "^8.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^25.3.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0" + } +} diff --git a/banking/proxyOptions.ts b/banking/proxyOptions.ts new file mode 100644 index 00000000000..e1aeca81094 --- /dev/null +++ b/banking/proxyOptions.ts @@ -0,0 +1,13 @@ +const common_site_config = require('../../../sites/common_site_config.json'); +const { webserver_port } = common_site_config; + +export default { + '^/(app|api|assets|files|private)': { + target: `http://127.0.0.1:${webserver_port}`, + ws: true, + router: function(req) { + const site_name = req.headers.host.split(':')[0]; + return `http://${site_name}:${webserver_port}`; + } + } +}; diff --git a/banking/src/App.tsx b/banking/src/App.tsx new file mode 100644 index 00000000000..2e6f8339ce9 --- /dev/null +++ b/banking/src/App.tsx @@ -0,0 +1,65 @@ +import { useEffect } from 'react' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { FrappeProvider } from 'frappe-react-sdk' +import { Toaster } from '@/components/ui/sonner' +import BankReconciliation from '@/pages/BankReconciliation' +import { TooltipProvider } from './components/ui/tooltip' +import BankStatementImporter from '@/pages/BankStatementImporter' +import { LucideProvider } from 'lucide-react' +import { ThemeProvider } from './components/ui/theme-provider' +import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog' +import BankStatementImporterContainer from './pages/BankStatementImporterContainer' + +function App() { + useEffect(() => { + // Check if user is logged in by checking the Cookie "user_id" + // In Frappe, unauthenticated users are "Guest" + const userId = document.cookie?.split('; ').find(row => row.startsWith('user_id='))?.split('=')[1]?.trim() + const isLoggedIn = userId !== 'Guest' + + if (!isLoggedIn) { + if (import.meta.env.DEV) { + return + } + // Redirect to Frappe login page + window.location.href = '/login?redirect-to=/banking' + return + } + }, []) + + return ( + + + + + {window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' && + + + + } /> + }> + } /> + } /> + + } /> + + + } + + + + + + ) +} + +export default App diff --git a/banking/src/components/common/AccountsDropdown.tsx b/banking/src/components/common/AccountsDropdown.tsx new file mode 100644 index 00000000000..a98ace578c3 --- /dev/null +++ b/banking/src/components/common/AccountsDropdown.tsx @@ -0,0 +1,228 @@ +import { Button } from "@/components/ui/button" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" +import _ from "@/lib/translate" +import { cn } from "@/lib/utils" +import { useFrappeGetDocList } from "frappe-react-sdk" +import Fuse from "fuse.js" +import { ChevronDownIcon } from "lucide-react" +import { useLayoutEffect, useMemo, useRef, useState } from "react" +import { FormControl } from "../ui/form" + + +export interface AccountsDropdownProps { + root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[], + report_type?: 'Balance Sheet' | 'Profit and Loss', + account_type?: string[], + value?: string, + onChange?: (value: string) => void, + readOnly?: boolean, + disabled?: boolean, + company?: string, + filterFunction?: (account: Account) => boolean, + // If true, the component will be wrapped in a FormControl component + useInForm?: boolean, + buttonClassName?: string, + size?: 'sm' | 'md' | 'lg', +} +/** + * Component to select an account - supports fuzzy search + * @param root_type - The root type of the account + * @param report_type - The report type of the account + * @param account_type - The type of the account + * @param value - The value of the account field + * @param onChange - The function to call when the value changes + * @returns + */ +const AccountsDropdown = ({ root_type, report_type, account_type, value, onChange, readOnly, disabled, company, filterFunction, useInForm, buttonClassName, size = 'md' }: AccountsDropdownProps) => { + + const { data } = useGetAccounts(root_type, report_type, account_type, company, filterFunction) + + const groupedAccounts = useMemo(() => { + if (!data) return [] + + const grouped: Record = data.reduce((acc, account) => { + const parentAccount = account.parent_account + if (!parentAccount) return acc + + if (!acc[parentAccount]) { + acc[parentAccount] = [] + } + + acc[parentAccount].push(account) + return acc + }, {} as Record) + + + return Object.entries(grouped).map(([parentAccount, accounts]) => ({ + // Remove the last abbreviation from the parent account name like "Assets - TCC" should be "Assets", and "Assets - USD - TCC" should be "Assets - USD" + parentAccount: parentAccount.split(" - ").slice(0, -1).join(" - "), + accounts + })) + + }, [data]) + + const searchIndex = useMemo(() => { + + if (!data) { + return null + } + + return new Fuse(data, { + keys: ['name'], + threshold: 0.5, + includeScore: true + }) + }, [data]) + + const [search, setSearch] = useState("") + + const recommendedAccounts = useMemo(() => { + + if (!searchIndex || !search) { + return [] + } + + return searchIndex.search(search).map((result) => result.item) + + }, [searchIndex, search]) + + const [open, setOpen] = useState(false) + + const onOpenChange = (open: boolean) => { + if (readOnly) return + setOpen(open) + // setSearch("") + } + + const onSelect = (value: string) => { + onChange?.(value) + setOpen(false) + setSearch(value) + } + + const buttonRef = useRef(null) + + const [width, setWidth] = useState(320) + + useLayoutEffect(() => { + if (buttonRef.current) { + setWidth(buttonRef.current.getBoundingClientRect().width) + } + }, []) + + return ( + + + {useInForm ? + + + : } + + + + + + {_("No accounts found.")} + + {recommendedAccounts.length > 0 && ( + + {recommendedAccounts.map((account) => ( + onSelect(account.name)}>{account.name} + ))} + + )} + + {!search && groupedAccounts.map((group) => ( + + {group.accounts.map((account) => ( + onSelect(account.name)}>{account.name} + ))} + + ))} + + + + + + + ) +} + + +interface Account { + name: string + root_type: 'Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense' + report_type: 'Balance Sheet' | 'Profit and Loss' + account_type: string + account_currency: string + parent_account: string +} + +export const useGetAccounts = (root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[], report_type?: 'Balance Sheet' | 'Profit and Loss', account_type?: string[], company?: string, + filterFunction?: (account: Account) => boolean) => { + + const currentCompany = useCurrentCompany() + const { data, isLoading, error, mutate } = useFrappeGetDocList("Account", { + fields: ["name", "root_type", "report_type", "account_type", "account_currency", "parent_account"], + filters: [["is_group", "=", 0], ["disabled", "=", 0], ["company", "=", company ?? currentCompany]], + limit: 1000, + orderBy: { + "field": "root_type", + // @ts-expect-error - we can pass in additional fields to orderBy + "order": "asc, account_number asc" + } + }, `accounts-${company ?? currentCompany}`, { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }) + + const filteredData = useMemo(() => { + + return data?.filter((account) => { + if (root_type && !root_type.includes(account.root_type)) return false + if (report_type && account.report_type !== report_type) return false + if (account_type && !account_type.includes(account.account_type)) return false + + if (filterFunction) return filterFunction(account) + return true + }) ?? [] + + }, [data, root_type, report_type, account_type, filterFunction]) + + return { data: filteredData, isLoading, error, mutate } +} + +export default AccountsDropdown \ No newline at end of file diff --git a/banking/src/components/common/BankLogo.tsx b/banking/src/components/common/BankLogo.tsx new file mode 100644 index 00000000000..9dd650dee4e --- /dev/null +++ b/banking/src/components/common/BankLogo.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils' +import { SelectedBank } from '../features/BankReconciliation/bankRecAtoms' +import { useTheme } from '../ui/theme-provider' +import { Landmark } from 'lucide-react' +import { H4 } from '../ui/typography' + +const BankLogo = ({ bank, className, imageClassName, iconSize = '18px', iconClassName }: { bank?: SelectedBank | null, className?: string, imageClassName?: string, iconSize?: string, iconClassName?: string }) => { + + const { themeValue } = useTheme() + return ( +
{bank?.logo ? {bank.bank : <> + +

{bank?.bank ?? ''}

+ + }
+ ) +} + +export default BankLogo \ No newline at end of file diff --git a/banking/src/components/common/FileUploadBanner.tsx b/banking/src/components/common/FileUploadBanner.tsx new file mode 100644 index 00000000000..dfa0ec7f8f3 --- /dev/null +++ b/banking/src/components/common/FileUploadBanner.tsx @@ -0,0 +1,17 @@ +import { CheckCircle } from 'lucide-react' +import { Progress } from '../ui/progress' +import _ from '@/lib/translate' + +const FileUploadBanner = ({ + uploadProgress, +}: { uploadProgress: number }) => { + return
+
+ + {_("The document has been created and reconciled. Uploading attachments...")} + +
+
+} + +export default FileUploadBanner \ No newline at end of file diff --git a/banking/src/components/common/LinkFieldCombobox.tsx b/banking/src/components/common/LinkFieldCombobox.tsx new file mode 100644 index 00000000000..a41105b05d7 --- /dev/null +++ b/banking/src/components/common/LinkFieldCombobox.tsx @@ -0,0 +1,301 @@ +import { useDocType } from "@/hooks/useDocType"; +import { getSystemDefault, slug } from "@/lib/frappe"; +import { Filter, useFrappeGetCall } from "frappe-react-sdk" +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { canCreateDocument } from "@/lib/permissions"; +import { useDebounceValue } from "usehooks-ts"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { FormControl } from "../ui/form"; +import { ChevronDownIcon, ExternalLink } from "lucide-react"; +import { Button } from "../ui/button"; +import { cn } from "@/lib/utils"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command"; +import _ from "@/lib/translate"; +import ErrorBanner from "../ui/error-banner"; +import MarkdownRenderer from "../ui/markdown"; + +export interface ResultItem { + value: string, + description: string, + label?: string +} + +export interface LinkFieldComboboxProps { + /** DocType to be fetched */ + doctype: string; + /** Filters to be applied. Default: none */ + filters?: Filter[] + /** Number of records to paginate with. Default: Comes from System Settings or 10 */ + limit?: number; + /** + * API to call to fetch records. + * + * Default: `frappe.desk.search.search_link` + * + * If you want to use a custom API, you can pass the path to the API here. + * + * The API should return a list of documents in the following format: + * [{value: string, description: string, label?: string}] - where the value is the ID of the document. + * + * If the API sends a label, it will be used as the label in the dropdown. + */ + searchAPIPath?: string; + /** + * Field you want to search against in the doctype. + * + * Default: `name` + * + * If you want to search against a different field, you can pass the fieldname here. + * + * If you want to search against multiple fields, you can try using the `searchAPIPath` prop to call a custom API, + * or use a custom query in the `customQuery` prop. + */ + searchfield?: string; + /** + * Custom query to be used to fetch records. + * + * If you want to use a custom query, you can pass the query here. + * + * The query should be in the following format: + * { + * query: string, + * filters: { + * fieldname: string, + * operator: string, + * value: string + * } + * } + */ + customQuery?: { + /** Path to function for the query. + * + * Refer: Item/Supplier query + */ + query: string, + /** Filters are usually an object instead of an array in a custom query */ + filters?: Record, + }, + /** + * Used for certain queries where a reference doctype is needed. + * + * For example when searching a supplier in a "Purchase Invoice", the reference_doctype is "Purchase Invoice" + */ + reference_doctype?: string, + /** Placeholder for the dropdown. Default: `doctype` */ + placeholder?: string; + /** + * Should the field be read-only. + */ + readOnly?: boolean; + /** Should the field be disabled. Default: false */ + disabled?: boolean; + /** + * Function to filter the options based on the input value/other criteria. + * + * For example, you might want to limit the companies shown in the dropdown since they have been already added (like in Cost Codes) + */ + filterFn?: (option: ResultItem, inputValue: string) => boolean, + value?: string, + onChange: (value: string) => void, + /** If true, the component will be wrapped in a FormControl component */ + useInForm?: boolean, + /** Button Class name */ + buttonClassName?: string, + size?: 'sm' | 'md' | 'lg', +} +const LinkFieldCombobox = ({ + doctype, + reference_doctype, + filters = [], + value, + onChange, + readOnly, + disabled, + filterFn, + placeholder = `Select ${doctype}`, + customQuery, + searchfield, + searchAPIPath = "frappe.desk.search.search_link", + limit, + useInForm, + buttonClassName, + size = 'md' +}: LinkFieldComboboxProps) => { + + const pageLimit = useMemo(() => limit || getSystemDefault('link_field_results_limit') || 20, [limit]) + + /** Load the Doctype meta so that we can determine the search fields + the name of the title field */ + const { data: meta } = useDocType(doctype) + + const userCanCreate = useMemo(() => canCreateDocument(doctype), [doctype]) + + const [open, setOpen] = useState(false) + + const [searchInput, setSearchInput] = useDebounceValue('', 400) + + const { data: linkTitleData } = useFrappeGetCall('frappe.client.get_value', { + doctype, + filters: JSON.stringify({ + name: value + }), + fieldname: meta?.title_field + }, (meta?.show_title_field_in_link ?? false) && (meta?.title_field) && value ? `link_title::${doctype}::${value}` : null, { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }) + + const linkTitle = meta?.title_field && meta?.show_title_field_in_link ? (linkTitleData?.message?.[meta?.title_field] ?? value) : value + + const buttonRef = useRef(null) + + const [width, setWidth] = useState(320) + + useLayoutEffect(() => { + if (buttonRef.current) { + setWidth(buttonRef.current.getBoundingClientRect().width) + } + }, []) + + const { data, error, isLoading } = useFrappeGetCall<{ message: ResultItem[] }>(searchAPIPath, { + doctype, + txt: searchInput, + page_length: pageLimit, + query: customQuery?.query, + searchfield, + filters: JSON.stringify(customQuery?.filters || filters || []), + reference_doctype, + }, () => { + if (!open) { + return null + } else { + let key = `${searchAPIPath}_${doctype}_${searchInput}` + + if (pageLimit) { + key += `_${pageLimit}` + } + + if (customQuery?.filters) { + key += `_${JSON.stringify(customQuery.filters)}` + } else if (filters) { + key += `_${JSON.stringify(filters)}` + } + + if (customQuery && customQuery.query) { + key += `_${customQuery.query}` + } + + if (reference_doctype) { + key += `_${reference_doctype}` + } + + if (searchfield && searchfield !== 'name') { + key += `_${searchfield}` + } + + return key + + } + }, { + revalidateOnFocus: false, + revalidateIfStale: false, + shouldRetryOnError: false, + revalidateOnReconnect: false, + }) + + const onOpenChange = (open: boolean) => { + if (readOnly) return + setOpen(open) + setSearchInput("") + } + + const onSelect = (value: string) => { + onChange?.(value) + setOpen(false) + } + + const items = filterFn ? data?.message?.slice(0, 50).filter((item) => filterFn(item, searchInput)) : data?.message + + const buttonProps = { + variant: "subtle", + type: 'button', + size: size, + role: "combobox", + "data-state": open ? "open" : "closed", + ref: buttonRef, + tabIndex: 0, + disabled: disabled || readOnly, + "aria-expanded": open, + "aria-readonly": readOnly, + className: cn("w-full justify-between font-normal group border border-transparent outline-none", + "data-[state=open]:bg-surface-white data-[state=open]:border-outline-gray-4 data-[state=open]:shadow-sm", + readOnly ? "bg-surface-gray-1" : "", + // Placeholder and value styling + linkTitle ? "text-ink-gray-7" : "text-ink-gray-4", + buttonClassName) + } as const + + return ( + + + {useInForm ? + + + : } + + + {error && } + + + + {isLoading ? _("Loading...") : _("No results found.")} + + {items?.map((result) => ( + onSelect(result.value)} className="flex flex-col items-start gap-0.5"> + + {result.label || result.value} + + {result.description && + + } + + ))} + {userCanCreate && + + {_("Create New {0}", [doctype])} + + + + + } + + + + + + + + + ) +} + +export default LinkFieldCombobox \ No newline at end of file diff --git a/banking/src/components/common/PartyTypeDropdown.tsx b/banking/src/components/common/PartyTypeDropdown.tsx new file mode 100644 index 00000000000..7bc6addf9de --- /dev/null +++ b/banking/src/components/common/PartyTypeDropdown.tsx @@ -0,0 +1,82 @@ +import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select' +import _ from '@/lib/translate' +import { useFrappeGetDocList } from 'frappe-react-sdk' +import { ComponentProps, useMemo } from 'react' +import { FormControl } from '../ui/form' + +export type PartyTypeDropdownProps = { + value?: string, + onChange?: (value: string) => void, + readOnly?: boolean, + disabled?: boolean, + /** Set this to order the parties so that suggested types are shown first */ + type?: 'Receivable' | 'Payable' + /** Set this to true if you want to hide other options by type. e.g. - if type is Receivable, Payable options like "Supplier" will be hidden */ + hideOptionsByType?: boolean, + valueProps?: ComponentProps, + triggerProps?: ComponentProps, + // If true, the component will be wrapped in a FormControl component + useInForm?: boolean +} + +const PartyTypeDropdown = ({ value, onChange, readOnly, disabled, type, hideOptionsByType, valueProps, triggerProps, useInForm }: PartyTypeDropdownProps) => { + + const { data } = useFrappeGetDocList("Party Type", { + fields: ['name', 'account_type'], + orderBy: { + field: 'creation', + order: 'asc' + } + }, `party_types`, { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }) + + const filteredData = useMemo(() => { + + let options = data ?? [ + { name: "Customer", account_type: "Receivable" }, + { name: "Supplier", account_type: "Payable" }, + { name: "Employee", account_type: "Payable" }, + { name: "Shareholder", account_type: "Payable" }, + ] + + if (hideOptionsByType && type) { + options = options.filter((option) => option.account_type === type) + } + + // Order by type if type is set + if (type) { + options = options.sort((a) => a.account_type === type ? -1 : 1) + } + + return options + }, [data, type, hideOptionsByType]) + + const onSelect = (value: string) => { + if (!readOnly) { + onChange?.(value) + } + } + + return ( + + ) +} + +export default PartyTypeDropdown \ No newline at end of file diff --git a/banking/src/components/features/ActionLog/ActionLog.tsx b/banking/src/components/features/ActionLog/ActionLog.tsx new file mode 100644 index 00000000000..e8ed9ae234a --- /dev/null +++ b/banking/src/components/features/ActionLog/ActionLog.tsx @@ -0,0 +1,475 @@ +import { Button } from '@/components/ui/button' +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import _ from '@/lib/translate' +import { useAtomValue, useSetAtom } from 'jotai' +import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react' +import { useMemo, useState } from 'react' +import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms' +import { useHotkeys } from 'react-hotkeys-hook' +import { useGetBankAccounts } from '../BankReconciliation/utils' +import { getCompanyCurrency } from '@/lib/company' +import { formatCurrency } from '@/lib/numbers' +import dayjs from 'dayjs' +import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/date' +import { Separator } from '@/components/ui/separator' +import { slug } from '@/lib/frappe' +import { PaymentEntry } from '@/types/Accounts/PaymentEntry' +import { JournalEntry } from '@/types/Accounts/JournalEntry' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk' +import { toast } from 'sonner' +import { getErrorMessage } from '@/lib/frappe' +import ErrorBanner from '@/components/ui/error-banner' +import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails' +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty' +import BankLogo from '@/components/common/BankLogo' + +const ActionLog = () => { + + const [isOpen, setIsOpen] = useState(false) + + useHotkeys('meta+z', () => { + setIsOpen(true) + }, { + enabled: true, + enableOnFormTags: false, + preventDefault: true + }) + + return ( + + + + + + + + + {_("Reconciliation History")} + + + + + {_("Reconciliation History")} + {_("View all reconciliation actions taken in this session.")} + + + + + + + + + + ) +} + +const ActionLogDialogContent = () => { + + const actionLog = useAtomValue(bankRecActionLog) + + return
+ {actionLog.map((action) => ( +
+ +
+
+
+ {action.items.map((item, index) => ( + + ))} +
+
+
+
+ ))} + + {actionLog.length === 0 && + + + + + {_("No reconciliation actions found")} + {_("You have not performed any reconciliations in this session yet.")} + + } +
+} + + + +const ActionGroupHeader = ({ action }: { action: ActionLogType }) => { + + const label = useMemo(() => { + switch (action.type) { + case 'match': + return _("Matched") + case 'payment': + if (action.isBulk) { + return _("Bulk Payment") + } + return _("Payment") + + case 'transfer': + if (action.isBulk) { + return _("Bulk Transfer") + } + return _("Transfer") + + case 'bank_entry': + if (action.isBulk) { + return _("Bulk Bank Entry") + } + return _("Bank Entry") + + default: + return _("Action") + } + }, [action]) + + return
+ {action.type === 'match' && } + {action.type === 'payment' && } + {action.type === 'transfer' && } + {action.type === 'bank_entry' && } + + {label} - {dayjs(action.timestamp).fromNow()} + +
+} + +const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => { + + const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + if (item.bankTransaction.bank_account) { + return banks?.find((bank) => bank.name === item.bankTransaction.bank_account) + } + return null + }, [item.bankTransaction.bank_account, banks]) + + const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit + + const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '') + + return
+
+
+
+

{item.bankTransaction.description}

+
+
+ + {item.bankTransaction.bank_account} +
+ +
+ + {formatDate(item.bankTransaction.date, 'Do MMM YYYY')} +
+ +
+
+ {isWithdrawal ? : } + {formatCurrency(amount, currency)} +
+
+
+
+
+
+ + {["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name} + + {item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && } + {item.voucher.reference_doctype === "Journal Entry" && } +
+
+
+
+
+ +
+
+} + +const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { + + return
+ + +
+} + +const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { + + const accounts = useMemo(() => { + + const allAccounts = (item.voucher.doc as JournalEntry).accounts + + return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true) + + }, [item, bank]) + + return <> + {accounts.length === 1 ? {accounts[0].account} : + + + {_("Split across {} accounts", [accounts.length.toString()])} + + + + + + {_("Account")} + {_("Debit")} + {_("Credit")} + + + + {accounts.map((account) => ( + + {account.account} + {formatCurrency(account.debit ?? 0, account.account_currency ?? '')} + {formatCurrency(account.credit ?? 0, account.account_currency ?? '')} + + ))} + +
+
+
+ } +} + +const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { + if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") { + return + } + + const invoices = (item.voucher.doc as PaymentEntry).references ?? [] + + const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency + + return
+
+ + {(item.voucher.doc as PaymentEntry).party_name} +
+ + + +
+ + {invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])} +
+
+ +
+ {invoices.map((invoice) => ( + + + + {_("Document")} + {_("Invoice No")} + {_("Due Date")} + {_("Grand Total")} + {_("Allocated")} + + + + + {invoice.reference_doctype}: {invoice.reference_name} + {invoice.bill_no ?? "-"} + {formatDate(invoice.due_date)} + {formatCurrency(invoice.total_amount, currency ?? '')} + {formatCurrency(invoice.allocated_amount, currency ?? '')} + + +
+ ))} +
+
+
+ +
+} + +const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + + const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 + + let transferAccount = "" + + if (isWithdrawal) { + transferAccount = (item.voucher.doc as PaymentEntry).paid_to + } else { + transferAccount = (item.voucher.doc as PaymentEntry).paid_from + } + + const transferBankAccount = banks?.find((bank) => bank.account === transferAccount) + + return transferBankAccount + + }, [banks, item]) + + return
+ + {bank?.account} +
+} + +const ACTION_TYPE_MAP = { + 'bank_entry': _("Bank Entry"), + 'payment': _("Payment"), + 'transfer': _("Transfer"), + 'match': _("Match"), +} + +const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => { + + const [isOpen, setIsOpen] = useState(false) + + const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry') + const { mutate } = useSWRConfig() + const actionLog = useSetAtom(bankRecActionLog) + const dates = useAtomValue(bankRecDateAtom) + const matchFilters = useAtomValue(bankRecMatchFilters) + const selectedBank = useAtomValue(selectedBankAccountAtom) + + const onUndo = () => { + call({ + bank_transaction_id: item.bankTransaction.name, + voucher_type: item.voucher.reference_doctype, + voucher_id: item.voucher.reference_name, + }).then(() => { + toast.success(type === 'match' ? _("Unmatched") : _("Cancelled")) + + if (selectedBank?.name === item.bankTransaction.bank_account) { + mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`) + // Update the matching vouchers for the selected transaction + mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`) + } + + setTimeout(() => { + actionLog((prev) => { + // Find the action and then remove the item from the action. If the action is empty, remove the action from the array + const action = prev.find((action) => action.timestamp === timestamp) + + if (action) { + action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name) + } + // If the action is empty, remove the action from the array + if (action && action.items.length === 0) { + return prev.filter((a) => a.timestamp !== timestamp) + } else { + return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a) + } + }) + }, 100) + + setIsOpen(false) + + }).catch((error) => { + toast.error(_("There was an error while performing the action."), { + duration: 5000, + description: getErrorMessage(error), + }) + }) + } + + return + + + + + + + + {_("Cancel")} + + + + + {type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])} + {type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])} + + {error && } +
+ + + + {_("Action Type")} + {ACTION_TYPE_MAP[type]} + + + {_("Voucher Type")} + {_(item.voucher.reference_doctype)} + + + {_("Voucher Name")} + {item.voucher.reference_name} + + + {_("Posting Date")} + {formatDate(item.voucher.posting_date, 'Do MMM YYYY')} + + {type === 'transfer' && item.voucher.doc && + {_("Transfer Account")} + + + + } + {type === 'payment' && item.voucher.doc && + {_("Payment Details")} + + + + } + {type === 'bank_entry' && item.voucher.doc && + {_("Account")} + + } +
+
+ + + {_("Close")} + + + +
+
+} + +export default ActionLog \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/BankBalance.tsx b/banking/src/components/features/BankReconciliation/BankBalance.tsx new file mode 100644 index 00000000000..7a7b0a4925c --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankBalance.tsx @@ -0,0 +1,334 @@ +import { useAtomValue, useSetAtom } from "jotai" +import { bankRecClosingBalanceAtom, bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" +import { FrappeConfig, FrappeContext, useFrappeGetDocCount, useFrappeGetDocList, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import { Progress } from "@/components/ui/progress" +import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils" +import { flt, formatCurrency } from "@/lib/numbers" +import { Skeleton } from "@/components/ui/skeleton" +import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats" +import { Edit, Info, Trash2 } from "lucide-react" +import { H4, Paragraph } from "@/components/ui/typography" +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card" +import { getCompanyCurrency } from "@/lib/company" +import _ from "@/lib/translate" +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { formatDate } from "@/lib/date" +import { Form } from "@/components/ui/form" +import { CurrencyFormField } from "@/components/ui/form-elements" +import { useForm } from "react-hook-form" +import { Button } from "@/components/ui/button" +import { useContext, useState } from "react" +import { Separator } from "@/components/ui/separator" +import { BankAccountBalance } from "@/types/Accounts/BankAccountBalance" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "sonner" +import ErrorBanner from "@/components/ui/error-banner" + +const BankBalance = () => { + + const bankAccount = useAtomValue(selectedBankAccountAtom) + + if (!bankAccount) { + return null + } + return ( +
+
+ + + + +
+ + +
+ ) +} + +const OpeningBalance = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const { data, isLoading } = useGetAccountOpeningBalance() + + return + {_("Opening Balance")} + {isLoading ? : {formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}} + +} + +const ClosingBalance = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const { data, isLoading } = useGetAccountClosingBalance() + + return ( + +
+ + {_("Closing Balance as per system")} + + + + + + +

{_("Closing balance as per system")}

+ + {_("This is what the system expects the closing balance to be in your bank statement.")} +
+ {_("It takes into account all the transactions that have been posted and subtracts the transactions that have not cleared yet.")} +
+ {_("If your bank statement shows a different closing balance, it is because all transactions have not reconciled yet.")} +

+ For more information, click on the Bank Reconciliation Statement tab below. +
+
+
+ +
+ {isLoading ? : {formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}} +
+ ) +} + +const Difference = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + + const { data, isLoading } = useGetAccountClosingBalance() + + const value = useAtomValue(bankRecClosingBalanceAtom(bankAccount?.name ?? '')) + + const difference = flt(value.value - (data?.message ?? 0)) + + const isError = difference !== 0 + + return + {_("Difference")} + {isLoading ? : + {formatCurrency(difference, + bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')) + }} + +} + +const ReconcileProgress = () => { + + const bankAccount = useAtomValue(selectedBankAccountAtom) + + const dates = useAtomValue(bankRecDateAtom) + + const { data: totalCount } = useFrappeGetDocCount('Bank Transaction', [ + ["bank_account", "=", bankAccount?.name ?? ''], + ['docstatus', '=', 1], + ['date', '<=', dates?.toDate], + ['date', '>=', dates?.fromDate] + ], false, undefined, { + revalidateOnFocus: false + }) + + const { data: unreconciledTransactions, } = useGetUnreconciledTransactions() + + const reconciledCount = (totalCount ?? 0) - (unreconciledTransactions?.message?.length ?? 0) + + const progress = (totalCount ? reconciledCount / totalCount : 0) * 100 + + return
+
+ +
+
+} + +const ClosingBalanceAsPerStatement = () => { + + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? '')) + + const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({ + onSuccess: (data) => { + if (data?.message && data?.message?.balance) { + setValue({ + value: data?.message?.balance, + stringValue: data?.message?.balance.toString() + }) + } + } + }) + + const isDateSame = data?.message?.date === dates.toDate + + const [isOpen, setIsOpen] = useState(false) + + + return + {_("Closing Balance as per statement")} +
+ + + + +
+ {isLoading ? : {formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}} + +
+
+ + {_("Click to set the closing balance as per statement")} + +
+
+ + setIsOpen(false)} + /> + + + +
+ {!isDateSame && data?.message.date && {_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}} +
+
+ +} + +const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => { + + const { mutate } = useSWRConfig() + + const form = useForm<{ balance: number }>({ + defaultValues: { + balance: defaultBalance + } + }) + + const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? '')) + + const { call, loading, error } = useFrappePostCall("erpnext.accounts.doctype.bank_account.bank_account.set_closing_balance_as_per_statement") + + const onSubmit = (data: { balance: number }) => { + if (data.balance) { + call({ + bank_account: bankAccount?.name ?? '', + date: date, + balance: data.balance + }) + .then(() => { + // Mutate the closing balance as per statement + mutate(`bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${date}`) + setValue({ + value: data.balance, + stringValue: data.balance.toString() + }) + toast.success(_("Closing balance set.")) + onClose() + + + }) + } else { + toast.error(_("Closing balance is required.")) + } + } + + const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '') + + + return
+ + + {_("Set closing balance as per bank statement")} + + {_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])} + + + {error && } +
+ +
+ + + + + + + + + + + +} + +const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank | null, date: string }) => { + + const { data, mutate } = useFrappeGetDocList("Bank Account Balance", { + filters: [["bank_account", "=", bankAccount?.name ?? ''], ["date", "<=", date]], + orderBy: { + field: "date", + order: "desc" + }, + fields: ["date", "balance", "name"], + limit: 10 + }) + + const { db } = useContext(FrappeContext) as FrappeConfig + + const onDelete = (name: string) => { + toast.promise(db.deleteDoc("Bank Account Balance", name).then(() => { + mutate() + }), { + loading: _("Deleting closing balance..."), + success: _("Closing balance deleted."), + error: _("Failed to delete closing balance.") + }) + } + + if (data?.length === 0) { + return null + } + + return
+ +

{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}

+ + + + {_("Date")} + {_("Balance")} + + + + + {data?.map((item) => ( + + {formatDate(item.date, 'Do MMM YYYY')} + {formatCurrency(flt(item.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))} + + + + + ))} + +
+
+ +} + +export default BankBalance \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx new file mode 100644 index 00000000000..077ee41ccd2 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx @@ -0,0 +1,355 @@ +import { useAtomValue } from "jotai" +import { MissingFiltersBanner } from "./MissingFiltersBanner" +import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" +import { Paragraph } from "@/components/ui/typography" +import type { ColumnDef } from "@tanstack/react-table" +import { useCallback, useMemo, useState } from "react" +import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" +import { QueryReportReturnType } from "@/types/custom/Reports" +import { formatDate } from "@/lib/date" +import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view" +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { formatCurrency } from "@/lib/numbers" +import { getCompanyCurrency } from "@/lib/company" +import { slug } from "@/lib/frappe" +import { CheckCircle2, ReceiptTextIcon, XCircle } from "lucide-react" +import ErrorBanner from "@/components/ui/error-banner" +import { Badge } from "@/components/ui/badge" +import _ from "@/lib/translate" +import { useCopyToClipboard } from "usehooks-ts" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Form } from "@/components/ui/form" +import { useForm } from "react-hook-form" +import { DateField } from "@/components/ui/form-elements" +import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty" + +const BankClearanceSummary = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + if (!bankAccount) { + return + } + + if (!dates) { + return + } + + return +} +interface BankClearanceSummaryEntry { + payment_document_type: string + payment_entry: string + posting_date: string, + cheque_no?: string, + amount: number, + against: string, + clearance_date: string, +} + +const BankClearanceSummaryView = () => { + + const companyID = useCurrentCompany() + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + const filters = useMemo(() => { + return JSON.stringify({ + account: bankAccount?.account, + from_date: dates.fromDate, + to_date: dates.toDate + }) + }, [bankAccount, dates]) + + const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType }>('frappe.desk.query_report.run', { + report_name: 'Bank Clearance Summary', + filters, + ignore_prepared_report: 1, + are_default_filters: false, + }, `Report-Bank Clearance Summary-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST') + + const formattedFromDate = formatDate(dates.fromDate) + const formattedToDate = formatDate(dates.toDate) + + const [, copyToClipboard] = useCopyToClipboard() + + const onCopy = useCallback( + (text: string) => { + copyToClipboard(text).then(() => { + toast.success(_("Copied to clipboard")) + }) + }, + [copyToClipboard, _], + ) + + const accountCurrency = useMemo( + () => bankAccount?.account_currency ?? getCompanyCurrency(companyID), + [bankAccount?.account_currency, companyID], + ) + + const clearanceColumns = useMemo[]>( + () => [ + { + accessorKey: "payment_document_type", + header: _("Document Type"), + size: 140, + cell: ({ row }) => _(row.original.payment_document_type), + }, + { + id: "payment_entry", + header: _("Payment Document"), + size: 160, + meta: { + getTooltipText: (r) => { + const x = r as BankClearanceSummaryEntry + return [x.payment_document_type, x.payment_entry].filter(Boolean).join(" · ") || undefined + }, + } satisfies ListViewColumnMeta, + cell: ({ row }) => ( + + {row.original.payment_entry} + + ), + }, + { + accessorKey: "posting_date", + header: _("Posting Date"), + size: 118, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.posting_date), + }, + { + accessorKey: "cheque_no", + header: _("Cheque/Reference Number"), + size: 160, + cell: ({ row }) => { + const ref = row.original.cheque_no ?? "" + return ( + + + + + + {ref} + + + + ) + }, + }, + { + accessorKey: "clearance_date", + header: _("Clearance Date"), + size: 118, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.clearance_date), + }, + { + accessorKey: "against", + header: _("Against Account"), + size: 250, + }, + { + accessorKey: "amount", + header: _("Amount"), + size: 150, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => {formatCurrency(row.original.amount, accountCurrency)}, + }, + { + id: "status", + header: _("Status"), + size: 200, + meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta, + cell: ({ row }) => { + const r = row.original + return r.clearance_date ? ( + + + {_("Cleared")} + + ) : ( +
+ + + {_("Not Cleared")} + + +
+ ) + }, + }, + ], + [_, accountCurrency, bankAccount, companyID, mutate, onCopy], + ) + + return
+ +
+ + ${bankAccount?.account}`, `${formattedFromDate}`, `${formattedToDate}`]) + }} /> + +
+ + {error && } + + {data && data.message.result.length > 0 ? ( + `${row.payment_entry}-${row.posting_date}`} + maxHeight="calc(100vh - 200px)" + scrollAreaClassName="min-h-[calc(100vh-200px)]" + emptyState={_("No rows to display.")} + /> + ) : null} + + {data && data.message.result.length == 0 && + + + + + + {_("No entries found")} + {_("There are no accounting entries in the system for the selected account and dates.")} + + + } + + +
+} + +const SetClearanceDateButton = ({ voucher, bankAccount, companyID, mutate }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank | null, companyID: string, mutate: VoidFunction }) => { + + const [open, setOpen] = useState(false) + + const onClose = () => { + setOpen(false) + mutate() + } + + return + + + + + + + {_("Set the clearance date for this voucher without reconciling with a bank transaction.")} + + + + + {bankAccount && } + + +} + +const ForceClearVoucherForm = ({ voucher, bankAccount, companyID, onClose }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank, companyID: string, onClose: () => void }) => { + + const { mutate } = useSWRConfig() + + const dates = useAtomValue(bankRecDateAtom) + const form = useForm<{ clearance_date: string }>({ + defaultValues: { + clearance_date: voucher.posting_date, + } + }) + + const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.update_clearance_date') + + const onSubmit = (data: { clearance_date: string }) => { + call({ + payment_document: voucher.payment_document_type, + payment_entry: voucher.payment_entry, + account: bankAccount.account, + clearance_date: data.clearance_date, + }) + .then(() => { + toast.success(_("Clearance date updated")) + onClose() + mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`) + }) + } + + return
+ + +
+ + + {_("Force Clear Voucher")} + + {_("Set the clearance date for this voucher without reconciling with a bank transaction.")} + + + {error && } +
+ + + + {_("Payment Document")} + {_(voucher.payment_document_type)} : {voucher.payment_entry} + + + {_("Posting Date")} + {formatDate(voucher.posting_date)} + + + {_("Cheque/Reference Number")} + {voucher.cheque_no?.slice(0, 40)}{voucher.cheque_no?.length && voucher.cheque_no?.length > 40 ? "..." : ""} + + + {_("Amount")} + {formatCurrency(voucher.amount, bankAccount?.account_currency ?? getCompanyCurrency(companyID))} + + + {_("Against Account")} + {voucher.against} + + +
+
+ + + + + + + + +
+
+ +} + +export default BankClearanceSummary diff --git a/banking/src/components/features/BankReconciliation/BankEntryModal.tsx b/banking/src/components/features/BankReconciliation/BankEntryModal.tsx new file mode 100644 index 00000000000..e6514e5d19c --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankEntryModal.tsx @@ -0,0 +1,831 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog" +import _ from "@/lib/translate" +import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils" +import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form" +import { JournalEntry } from "@/types/Accounts/JournalEntry" +import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" +import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk" +import { toast } from "sonner" +import ErrorBanner from "@/components/ui/error-banner" +import { Button } from "@/components/ui/button" +import SelectedTransactionDetails from "./SelectedTransactionDetails" +import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements" +import { Form } from "@/components/ui/form" +import { useCallback, useContext, useMemo, useRef, useState } from "react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react" +import { flt, formatCurrency } from "@/lib/numbers" +import { cn } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import SelectedTransactionsTable from "./SelectedTransactionsTable" +import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import FileUploadBanner from "@/components/common/FileUploadBanner" +import { Label } from "@/components/ui/label" +import { FileDropzone } from "@/components/ui/file-dropzone" +import { useGetAccounts } from "@/components/common/AccountsDropdown" +import { useHotkeys } from "react-hotkeys-hook" + +const BankEntryModal = () => { + + const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom) + + return ( + + + + {_("Bank Entry")} + + {_("Record a journal entry for expenses, income or split transactions.")} + + + + + + ) +} + +const RecordBankEntryModalContent = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + + if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { + return
+ {_("No transaction selected")} +
+ } + + if (selectedTransaction.length === 1) { + return + } + + return + +} + +const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => { + + const form = useForm<{ + account: string + }>({ + defaultValues: { + account: '' + } + }) + + const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile') + + const onReconcile = useRefreshUnreconciledTransactions() + const addToActionLog = useUpdateActionLog() + + const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + const onSubmit = (data: { account: string }) => { + + call({ + bank_transactions: selectedTransactions.map(transaction => transaction.name), + account: data.account + }).then(({ message }) => { + + addToActionLog({ + type: 'bank_entry', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Journal Entry", + reference_name: item.journal_entry.name, + doc: item.journal_entry, + posting_date: item.journal_entry.posting_date, + } + })), + bulkCommonData: { + account: data.account, + } + }) + + toast.success(_("Bank Entries Created"), { + duration: 4000, + }) + + // Set this to the last selected transaction + onReconcile(selectedTransactions[selectedTransactions.length - 1]) + setIsOpen(false) + }) + } + + return
+ +
+ {error && } + + +
+ { + // Do not allow payable and receivable accounts + return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable' + }} + label={_('Account')} + isRequired + /> +
+ + + + + + + +
+
+ +} + + +interface BankEntryFormData extends Pick { + entries: JournalEntry['accounts'] +} + + +const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const defaultAccounts = useMemo(() => { + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const accounts: Partial[] = [ + { + account: selectedBankAccount?.account ?? '', + bank_account: selectedTransaction.bank_account, + // Bank is debited if it's a deposit + debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + party_type: '', + party: '', + cost_center: '' + }] + + // If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse + if (!rule) { + accounts.push( + { + account: '', + // Amounts will be the reverse of the bank account transaction + debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + } + ) + } else { + // Rule exists, so we need to check the type of rule + if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") { + // Only a single account needs to be added + accounts.push({ + account: rule.account ?? '', + // Amounts will be the reverse of the bank account transaction + debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + }) + } else { + // For multiple accounts, we need to loop over and add entries for each + // The last row will just be the remaining amount + let hasTotallyEmptyRowEarlier = false; + + let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0 + let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0 + + for (let i = 0; i < (rule.accounts?.length ?? 0); i++) { + + const acc = rule.accounts?.[i] + // If it's the last row, add the difference amount + if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) { + + const differenceAmount = flt(totalDebits - totalCredits, 2) + accounts.push({ + account: acc?.account ?? '', + debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount), + credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + user_remark: acc?.user_remark ?? '', + }) + } else { + + /** + * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" + * So we need to compute the value of the expression + * We can use the eval function to do this. But we need to expose certain variables to the expression. + * One of them is transaction_amount which is the unallocated amount of the selected transaction + * @param expression - The expression to compute + * @returns The computed value + */ + const computeExpression = (expression: string) => { + + const script = ` + const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} + ${expression}; + ` + + let value = 0; + + try { + value = window.eval(script); + } catch (error: unknown) { + console.error(error); + value = 0; + } + + return value; + } + if (!acc?.debit && !acc?.credit) { + hasTotallyEmptyRowEarlier = true; + } + + const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 + const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 + + totalDebits = flt(totalDebits + computedDebit, 2) + totalCredits = flt(totalCredits + computedCredit, 2) + accounts.push({ + account: acc?.account ?? '', + debit: computedDebit, + credit: computedCredit, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + user_remark: acc?.user_remark ?? '', + }) + } + } + } + } + + return accounts + + }, [rule, selectedTransaction, selectedBankAccount]) + + const form = useForm({ + defaultValues: { + voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry', + cheque_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + user_remark: selectedTransaction.description, + entries: defaultAccounts, + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + + const [files, setFiles] = useState([]) + + const onSubmit = (data: BankEntryFormData) => { + + createBankEntry({ + bank_transaction_name: selectedTransaction.name, + ...data + }).then(async ({ message }) => { + + addToActionLog({ + type: 'bank_entry', + isBulk: false, + timestamp: (new Date()).getTime(), + items: [ + { + bankTransaction: message.transaction, + voucher: { + reference_doctype: "Journal Entry", + reference_name: message.journal_entry.name, + reference_no: message.journal_entry.cheque_no, + reference_date: message.journal_entry.cheque_date, + posting_date: message.journal_entry.posting_date, + doc: message.journal_entry, + } + } + ] + }) + toast.success(_("Bank Entry Created"), { + duration: 4000, + closeButton: true, + action: { + label: _("Undo"), + onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) + }, + actionButtonStyle: { + backgroundColor: "rgb(0, 138, 46)" + } + }) + + if (files.length > 0) { + setIsUploading(true) + + const uploadPromises = files.map(f => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Journal Entry", + docname: message.journal_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + + setUploadProgress((currentProgress) => { + //If there are multiple files, we need to add the progress to the current progress + return currentProgress + ((progress?.progress ?? 0) / files.length) + }) + + }) + }) + + return Promise.all(uploadPromises).then(() => { + setUploadProgress(0) + setIsUploading(false) + }).catch((error) => { + console.error(error) + toast.error(_("Error uploading attachments"), { + duration: 4000, + }) + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + + }).then(() => { + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ + +
+
+ + +
+ +
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ + + + + + + +
+
+ + +} + +const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => { + + const { getValues, setValue, control } = useFormContext() + + const { call } = useContext(FrappeContext) as FrappeConfig + + const partyMapRef = useRef>({}) + + const onPartyChange = (value: string, index: number) => { + // Get the account for the party type + if (value) { + if (partyMapRef.current[value]) { + setValue(`entries.${index}.account`, partyMapRef.current[value]) + } else { + call.get('erpnext.accounts.party.get_party_account', { + party: value, + party_type: getValues(`entries.${index}.party_type`), + company: company + }).then((result: { message: string }) => { + setValue(`entries.${index}.account`, result.message) + partyMapRef.current[value] = result.message + }) + } + } else { + setValue(`entries.${index}.account`, '') + } + } + + const { data: accounts } = useGetAccounts() + + const onAccountChange = (value: string, index: number) => { + // If it's an income or expense account, get the default cost center + if (value) { + const account = accounts?.find((acc) => acc.name === value) + if (account && account.report_type === "Profit and Loss") { + // Set the default company cost center + setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '') + return + } + } + + setValue(`entries.${index}.cost_center`, '') + } + + const { fields, append, remove } = useFieldArray({ + control: control, + name: 'entries' + }) + + const onAdd = useCallback(() => { + const existingEntries = getValues('entries') + const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + + const remainingAmount = flt(totalDebits - totalCredits, 2) + + // Remaining amount is credit if it's positive - since some debit is pending to be cleared. + const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) + const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 + + append({ + party_type: '', + party: '', + account: '', + debit: debitAmount, + credit: creditAmount, + cost_center: getCompanyCostCenter(company) ?? '' + } as JournalEntryAccount, { + focusName: `entries.${existingEntries.length}.account` + }) + }, [company, append, getValues]) + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onRemove = useCallback(() => { + remove(selectedRows) + setSelectedRows([]) + }, [remove, selectedRows]) + + /** + * When add difference is clicked, check if the last row has nothing filled in. + * If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount. + */ + const onAddDifferenceClicked = () => { + + const existingEntries = getValues('entries') + const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + + const lastIndex = existingEntries.length - 1 + + const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined) + + const remainingAmount = flt(totalDebits - totalCredits, 2) + + // Remaining amount is credit if it's positive - since some debit is pending to be cleared. + const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) + const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 + + if (isLastRowEmpty) { + setValue(`entries.${lastIndex}.debit`, debitAmount) + setValue(`entries.${lastIndex}.credit`, creditAmount) + } else { + append({ + party_type: '', + party: '', + account: '', + debit: debitAmount, + credit: creditAmount, + cost_center: getCompanyCostCenter(company) ?? '' + } as JournalEntryAccount, { + focusName: `entries.${existingEntries.length}.account` + }) + } + } + + + + return
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Party")} + {_("Account")} + {_("Cost Center")} + {_("Remarks")} + {_("Debit")} + {_("Credit")} + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + disabled={index === 0} + /> + + + +
+ + +
+ +
+ + { + onAccountChange(event.target.value, index) + } + }} + buttonClassName="min-w-64" + readOnly={index === 0} + isRequired + hideLabel + /> + + + + + + + + + + + {_("Bank account debit for deposit")} + : undefined} + /> + + + + + {_("Bank account credit for withdrawal")} + : undefined} + /> + +
+ ))} +
+
+
+
+
+ +
+ {selectedRows.length > 0 &&
+ +
} +
+ +
+
+ +} + +const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => { + + const { control } = useFormContext() + + const party_type = useWatch({ + control, + name: `entries.${index}.party_type` + }) + + if (!party_type) { + return + } + + return { + onChange(event.target.value, index) + }, + }} + hideLabel + readOnly={readOnly} + buttonClassName="rounded-s-none border-s-0 min-w-64" + doctype={party_type} + + /> +} + +const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => { + + const { control } = useFormContext() + + const entries = useWatch({ control, name: 'entries' }) + + const { total, totalCredits, totalDebits } = useMemo(() => { + // Do a total debits - total credits + const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits } + }, [entries]) + + const onAddRow = useCallback(() => { + addRow() + }, [addRow]) + + const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { + return {children} + } + + return
+
+ {_("Total Debit")} + {formatCurrency(totalDebits, currency)} +
+
+ {_("Total Credit")} + {formatCurrency(totalCredits, currency)} +
+ {total !== 0 &&
+ {_("Difference")} + + + + + + {_("Add a row with the difference amount")} + + +
} + +
+ +} + + +export default BankEntryModal diff --git a/banking/src/components/features/BankReconciliation/BankPicker.tsx b/banking/src/components/features/BankReconciliation/BankPicker.tsx new file mode 100644 index 00000000000..9150103fd32 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankPicker.tsx @@ -0,0 +1,124 @@ +import { useAtom, useSetAtom } from "jotai" +import { SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" +import { useCallback } from "react" +import { useGetBankAccounts, useGetUnreconciledTransactions } from "./utils" +import { cn } from "@/lib/utils" +import { getTimeago } from "@/lib/date" +import ErrorBanner from "@/components/ui/error-banner" +import _ from "@/lib/translate" +import { Badge } from "@/components/ui/badge" +import { useTheme } from "@/components/ui/theme-provider" +import BankLogo from "@/components/common/BankLogo" +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { LandmarkIcon } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" + +const BankPicker = ({ className }: { className?: string }) => { + + const setSelectedBank = useSetAtom(selectedBankAccountAtom) + + const onLoadingSuccess = useCallback((data?: SelectedBank[]) => { + if (!data) return + if (data.length === 1) { + setSelectedBank(data[0]) + } else if (data.length > 1) { + const defaultBank = data.find((bank: SelectedBank) => bank.is_default) + if (defaultBank) { + setSelectedBank(defaultBank) + } + } + }, [setSelectedBank]) + + const selectedCompany = useCurrentCompany() + + const { banks, isLoading, error } = useGetBankAccounts(onLoadingSuccess) + + const { themeValue } = useTheme() + + if (isLoading) { + return null + } + + if (error) { + return + } + + if (banks?.length === 0) { + return + + + + + {_("No bank accounts found")} + {_("You have not added any bank accounts to your company.")} + + + + + + } + return ( +
4 ? 'pb-2' : '', className, + )} + style={{ + scrollbarWidth: 'thin', + scrollbarColor: themeValue === 'Dark' ? 'var(--surface-gray-2) var(--surface-gray-1)' : 'rgb(209 213 219) rgb(243 244 246)', + }} + > + { + banks?.map((bank) => ( + + )) + } +
+ ) +} + +const BankPickerItem = ({ bank }: { bank: SelectedBank }) => { + + const [selectedBank, setSelectedBank] = useAtom(selectedBankAccountAtom) + + const isSelected = selectedBank?.name === bank.name + + const { mutate } = useGetUnreconciledTransactions() + + const onSelect = () => { + setSelectedBank(bank) + mutate() + } + + return
+ + + + +
+
+ {bank.account_name} + {bank.account_type && + {bank.account_type?.slice(0, 24)} + } +
+ + {bank.account} + {bank.last_integration_date && {_("Last Synced Transaction")}: {getTimeago(bank.last_integration_date)}} +
+ +
+} + +export default BankPicker \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/BankRecDateFilter.tsx b/banking/src/components/features/BankReconciliation/BankRecDateFilter.tsx new file mode 100644 index 00000000000..84bd5278ccc --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankRecDateFilter.tsx @@ -0,0 +1,275 @@ +import { useAtom } from 'jotai' +import { bankRecDateAtom } from './bankRecAtoms' +import { useMemo, useState } from 'react' +import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod } from '@/lib/date' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react' +import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { parse } from "chrono-node" +import { Calendar } from '@/components/ui/calendar' +import useFiscalYear from '@/hooks/useFiscalYear' +import dayjs from 'dayjs' +import _ from '@/lib/translate' +import { useDirection } from '@/components/ui/direction' + +const BankRecDateFilter = () => { + + const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom) + + const { data: fiscalYear } = useFiscalYear() + + const timePeriodOptions = useMemo(() => { + const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => { + const dates = getDatesForTimePeriod(period) + return { + label: period, + fromDate: dates.fromDate, + toDate: dates.toDate, + format: dates.format, + translatedLabel: dates.translatedLabel + } + }) + + if (fiscalYear?.message) { + // For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters + const fiscalYearStart = fiscalYear.message.year_start_date + const fiscalYearEnd = fiscalYear.message.year_end_date + + const q1 = { + label: `Q1: ${fiscalYear.message.name}`, + translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`, + fromDate: fiscalYearStart, + toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'), + format: 'MMM YYYY' + } + + const q2 = { + label: `Q2: ${fiscalYear.message.name}`, + translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`, + fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'), + toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'), + format: 'MMM YYYY' + } + + const q3 = { + label: `Q3: ${fiscalYear.message.name}`, + translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`, + fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'), + toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'), + format: 'MMM YYYY' + } + + const q4 = { + label: `Q4: ${fiscalYear.message.name}`, + translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`, + fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'), + toDate: fiscalYearEnd, + format: 'MMM YYYY' + } + + const thisYear = { + label: `This Fiscal Year`, + translatedLabel: `${_("This Fiscal Year")}`, + fromDate: fiscalYearStart, + toDate: fiscalYearEnd, + format: 'MMM YYYY' + } + + const lastYear = { + label: `Last Fiscal Year`, + translatedLabel: `${_("Last Fiscal Year")}`, + fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'), + toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'), + format: 'MMM YYYY' + } + // Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options + + const topRankedItems = standardOptions.filter((option) => { + return option.label === "This Month" || option.label === "Last Month" + }) + + const bottomRankedItems = standardOptions.filter((option) => { + return option.label !== "This Month" && option.label !== "Last Month" + }) + + return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems] + } + + return standardOptions + }, [fiscalYear]) + + const [open, setOpen] = useState(false) + const [value, setValue] = useState("") + + const timePeriod: TimePeriod | string = useMemo(() => { + if (bankRecDate.fromDate && bankRecDate.toDate) { + // Check if the from and to dates match any predefined time period + for (const period of timePeriodOptions) { + if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) { + return period.label; + } + } + return "Date Range"; + } else { + return "Date Range"; + } + }, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]); + + const handleTimePeriodChange = (fromDate: string, toDate: string) => { + setBankRecDate({ fromDate, toDate }) + setOpen(false) + } + + const dateObj = useMemo(() => { + return { + from: new Date(bankRecDate.fromDate), + to: new Date(bankRecDate.toDate) + } + }, [bankRecDate.fromDate, bankRecDate.toDate]) + + const direction = useDirection() + + + + return
+ + + + + + + + + + + + + + {timePeriodOptions.map((period) => ( + handleTimePeriodChange(period.fromDate, period.toDate)}> + + {period.translatedLabel ?? _(period.label)} + + + {formatDate(period.fromDate, period.format)} {direction === 'ltr' ? : } {formatDate(period.toDate, period.format)} + + + ))} + + + + + + + + + + + + { + if (date) { + setBankRecDate({ fromDate: formatDate(date.from, 'YYYY-MM-DD'), toDate: formatDate(date.to, 'YYYY-MM-DD') }) + } + }} + /> + + +
+} + +const referentialKeywords = ["last", "this", "next", "previous"] +const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => { + + const dates = useMemo(() => { + if (value) { + // Try parsing the value + const parsedDate = parse(value, undefined, { forwardDate: false }) + + if (parsedDate && parsedDate.length > 0) { + const startDate = parsedDate[0].start.date() + const endDate = parsedDate[0].end?.date() + + if (!endDate) { + const today = new Date() + // If today is greater than the start date, use today as the end date + if (startDate.getTime() > today.getTime()) { + return { fromDate: today, toDate: startDate } + } else { + // Check if the user only wants a specific month like "May 2025" + // If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month + // @ts-expect-error - "Known Values" is available in the start "ParsingComponents" + if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) { + return { + fromDate: startDate, + toDate: dayjs(startDate).endOf('month').toDate() + } + // @ts-expect-error - "Known Values" is available in the start "ParsingComponents" + } else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) { + // If month and day is known, then we should not assume that the user wants to get everything until today + return { + fromDate: startDate, + toDate: startDate, + } + } + + return { + fromDate: startDate, + toDate: today + } + } + } else { + return { fromDate: startDate, toDate: endDate } + } + } + + } + }, [value]) + + const onClick = (fromDate: Date, toDate: Date) => { + onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD')) + } + + const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date') + + return
+ {dates ? +
onClick(dates.fromDate, dates.toDate)}> + + {value} + + {isEqual ? + {formatDate(dates.fromDate, 'Do MMM YYYY')} + : + + {formatDate(dates.fromDate, 'Do MMM YY')} {formatDate(dates.toDate, 'Do MMM YY')} + } +
: + + No results found + + } +
+} + +export default BankRecDateFilter \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx new file mode 100644 index 00000000000..acfed95aa15 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx @@ -0,0 +1,315 @@ +import { useAtomValue } from "jotai" +import { MissingFiltersBanner } from "./MissingFiltersBanner" +import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" +import { Paragraph } from "@/components/ui/typography" +import { useCallback, useMemo } from "react" +import type { ColumnDef } from "@tanstack/react-table" +import { useFrappeGetCall } from "frappe-react-sdk" +import { QueryReportReturnType } from "@/types/custom/Reports" +import { formatDate } from "@/lib/date" +import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view" +import { formatCurrency } from "@/lib/numbers" +import { getCompanyCurrency } from "@/lib/company" +import { slug } from "@/lib/frappe" +import { ScrollTextIcon } from "lucide-react" +import ErrorBanner from "@/components/ui/error-banner" +import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats" +import _ from "@/lib/translate" +import { toast } from "sonner" +import { useCopyToClipboard } from "usehooks-ts" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" + +const BankReconciliationStatement = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + if (!bankAccount) { + return + } + + if (!dates) { + return + } + + return +} +interface BankClearanceSummaryEntry { + payment_document: string + payment_entry: string + posting_date: string, + reference_no: string, + credit: number, + debit: number, + against_account: string, + ref_date: string, + account_currency: string, + clearance_date: string +} + +const BankReconciliationStatementView = () => { + + const companyID = useCurrentCompany() + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + const filters = useMemo(() => { + return JSON.stringify({ + account: bankAccount?.account, + report_date: dates.toDate, + company: companyID + }) + }, [bankAccount, dates, companyID]) + + const { data, error } = useFrappeGetCall<{ message: QueryReportReturnType }>('frappe.desk.query_report.run', { + report_name: 'Bank Reconciliation Statement', + filters, + ignore_prepared_report: 1, + are_default_filters: false, + }, `Report-Bank Reconciliation Statement-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST') + + const [, copyToClipboard] = useCopyToClipboard() + + const onCopy = useCallback( + (text: string) => { + copyToClipboard(text).then(() => { + toast.success(_("Copied to clipboard")) + }) + }, + [copyToClipboard, _], + ) + + const statementColumns = useMemo[]>( + () => [ + { + accessorKey: "posting_date", + header: _("Posting Date"), + size: 118, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.posting_date), + }, + { + accessorKey: "payment_document", + header: _("Document Type"), + size: 140, + cell: ({ row }) => _(row.original.payment_document), + }, + { + id: "payment_entry", + header: _("Payment Document"), + size: 300, + meta: { + getTooltipText: (r) => { + const x = r as BankClearanceSummaryEntry + const parts = [x.payment_document, x.payment_entry].filter(Boolean) + return parts.length ? parts.join(" · ") : undefined + }, + } satisfies ListViewColumnMeta, + cell: ({ row }) => { + const { payment_document, payment_entry } = row.original + return payment_document ? ( + + {payment_entry} + + ) : ( + payment_entry + ) + }, + }, + { + accessorKey: "debit", + header: _("Debit"), + size: 112, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => {formatCurrency(row.original.debit, row.original.account_currency)}, + }, + { + accessorKey: "credit", + header: _("Credit"), + size: 112, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => {formatCurrency(row.original.credit, row.original.account_currency)}, + }, + { + accessorKey: "against_account", + header: _("Against Account"), + meta: { gridWidth: "minmax(0,1.25fr)" } satisfies ListViewColumnMeta, + cell: ({ row }) => ( + + {row.original.against_account} + + ), + }, + { + accessorKey: "reference_no", + header: _("Reference #"), + cell: ({ row }) => { + const ref = row.original.reference_no + return ( + + ) + }, + }, + { + accessorKey: "ref_date", + header: _("Reference Date"), + size: 118, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.ref_date), + }, + { + accessorKey: "clearance_date", + header: _("Clearance Date"), + size: 118, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.clearance_date), + }, + ], + [_, onCopy], + ) + + const statementRows = useMemo(() => { + if (!data?.message.result) return [] + return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry)) + }, [data]) + + return
+ +
+ + ${bankAccount?.account}`, `${formatDate(dates.toDate)}`]) + }} /> + +
+ + {error && } + + {data && } + + {data && data.message.result.length > 0 && ( +
+

{_("Bank Reconciliation Statement")}

+ row.payment_entry} + maxHeight="min(70vh, 640px)" + emptyState={_("No entries with a payment document in this list.")} + /> +
+ )} + + {data && data.message.result.length === 0 && + + + + + + {_("No entries found")} + {_("There are no accounting entries in the system for the selected account and dates.")} + + + } + + +
+} + +const SummarySection = ({ data }: { data: { message: QueryReportReturnType } }) => { + + const company = useCurrentCompany() + const bankAccount = useAtomValue(selectedBankAccountAtom) + + const { bankStatementBalanceAsPerGL, outstandingChecksDebit, outstandingChecksCredit, incorrectlyClearedEntriesDebit, incorrectlyClearedEntriesCredit, calculatedBankStatementBalance } = useMemo(() => { + + // Loop over the results and find the corresponding rows + + let bankStatementBalanceAsPerGL = 0 + + let outstandingChecksDebit = 0 + let outstandingChecksCredit = 0 + + let incorrectlyClearedEntriesDebit = 0 + let incorrectlyClearedEntriesCredit = 0 + + let calculatedBankStatementBalance = 0 + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?.message.result.forEach((r: any) => { + if (r.payment_entry === 'Bank Statement balance as per General Ledger') { + bankStatementBalanceAsPerGL = r.debit - r.credit + } + + if (r.payment_entry === 'Outstanding Checks and Deposits to clear') { + outstandingChecksDebit = r.debit + outstandingChecksCredit = r.credit + } + + if (r.payment_entry === 'Checks and Deposits incorrectly cleared') { + incorrectlyClearedEntriesDebit = r.debit + incorrectlyClearedEntriesCredit = r.credit + } + + if (r.payment_entry === 'Calculated Bank Statement balance') { + calculatedBankStatementBalance = r.debit - r.credit + } + }) + + return { + bankStatementBalanceAsPerGL, + outstandingChecksDebit, + outstandingChecksCredit, + incorrectlyClearedEntriesDebit, + incorrectlyClearedEntriesCredit, + calculatedBankStatementBalance + } + + }, [data]) + + const currency = bankAccount?.account_currency ?? getCompanyCurrency(company) + + return
+ + {_("Bank Statement Balance as per General Ledger")} + {formatCurrency(bankStatementBalanceAsPerGL, currency)} + + + + {_("Outstanding Checks and Deposits to clear")} + {formatCurrency(outstandingChecksDebit - outstandingChecksCredit, currency)} + + + {(incorrectlyClearedEntriesDebit > 0 || incorrectlyClearedEntriesCredit > 0) && + {_("Checks and Deposits incorrectly cleared")} + {formatCurrency(incorrectlyClearedEntriesDebit - incorrectlyClearedEntriesCredit)} + {/*
}> + {incorrectlyClearedEntriesDebit !== 0 && Debit: {formatCurrency(incorrectlyClearedEntriesDebit)}} + {incorrectlyClearedEntriesCredit !== 0 && Credit: {formatCurrency(incorrectlyClearedEntriesCredit)}} +
*/} +
} + + {_("Calculated Bank Statement Balance")} + {formatCurrency(calculatedBankStatementBalance)} + + +
+} + +export default BankReconciliationStatement diff --git a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx new file mode 100644 index 00000000000..05e35f0f289 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx @@ -0,0 +1,419 @@ +import { useAtomValue, useSetAtom } from "jotai" +import { MissingFiltersBanner } from "./MissingFiltersBanner" +import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { Paragraph } from "@/components/ui/typography" +import { formatDate } from "@/lib/date" +import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view" +import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers" +import { getCompanyCurrency } from "@/lib/company" +import { ArrowDownRight, ArrowUpRight, CheckCircle2, ChevronDown, DollarSign, ExternalLink, ImportIcon, ListIcon, Search, Undo2, XCircle } from "lucide-react" +import ErrorBanner from "@/components/ui/error-banner" +import { Badge } from "@/components/ui/badge" +import { useGetBankTransactions } from "./utils" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import { Button } from "@/components/ui/button" +import _ from "@/lib/translate" +import { Input } from "@/components/ui/input" +import CurrencyInput from "react-currency-input-field" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { getCurrencySymbol } from "@/lib/currency" +import { useDebounceValue } from "usehooks-ts" +import type { ColumnDef } from "@tanstack/react-table" +import { useCallback, useMemo, useState } from "react" +import { Link } from "react-router" +import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription } from "@/components/ui/empty" +import { InputGroup, InputGroupAddon } from "@/components/ui/input-group" + +const BankTransactions = () => { + const selectedBank = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + if (!selectedBank || !dates) { + return + } + + return <> + + +} + +const BankTransactionListView = () => { + + const { data, error } = useGetBankTransactions() + + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + const formattedFromDate = formatDate(dates.fromDate) + const formattedToDate = formatDate(dates.toDate) + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + + const onUndo = useCallback( + (transaction: BankTransaction) => { + setBankRecUnreconcileModalAtom(transaction.name) + }, + [setBankRecUnreconcileModalAtom], + ) + + const accountCurrency = useMemo( + () => bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ""), + [bankAccount?.account_currency, bankAccount?.company], + ) + + const transactionColumns = useMemo[]>( + () => [ + { + accessorKey: "date", + header: _("Date"), + size: 112, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.date), + }, + { + accessorKey: "description", + header: _("Description"), + size: 250, + // meta: { gridWidth: "minmax(0,2fr)" } satisfies ListViewColumnMeta, + cell: ({ row }) => row.original.description, + }, + { + accessorKey: "reference_number", + header: _("Reference #"), + size: 128, + cell: ({ row }) => row.original.reference_number, + }, + { + accessorKey: "withdrawal", + header: _("Withdrawal"), + size: 120, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => {formatCurrency(row.original.withdrawal, accountCurrency)}, + }, + { + accessorKey: "deposit", + header: _("Deposit"), + size: 120, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => {formatCurrency(row.original.deposit, accountCurrency)}, + }, + { + accessorKey: "unallocated_amount", + header: _("Unallocated"), + size: 120, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => {formatCurrency(row.original.unallocated_amount, accountCurrency)}, + }, + { + accessorKey: "transaction_type", + header: _("Type"), + size: 112, + cell: ({ row }) => + row.original.transaction_type ? {row.original.transaction_type} : null, + }, + { + id: "status", + header: _("Status"), + size: 168, + meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta, + cell: ({ row }) => { + const tx = row.original + if (!tx.allocated_amount || (tx.allocated_amount && tx.allocated_amount === 0)) { + return ( + + + {_("Not Reconciled")} + + ) + } + if (tx.allocated_amount && tx.allocated_amount > 0 && tx.unallocated_amount !== 0) { + return ( + + + {_("Partially Reconciled")} + + ) + } + return ( + + + {_("Reconciled")} + + ) + }, + }, + { + id: "actions", + header: _("Actions"), + size: 200, + enableResizing: false, + meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta, + cell: ({ row }) => ( +
+ + {row.original.allocated_amount && row.original.allocated_amount > 0 ? ( + + ) : null} +
+ ), + }, + ], + [_, accountCurrency, onUndo], + ) + + const [search, setSearch] = useDebounceValue('', 250) + const [amountFilter, setAmountFilter] = useState<{ value: number, stringValue?: string | number }>({ value: 0, stringValue: '0.00' }) + const [typeFilter, setTypeFilter] = useState('All') + const [status, setStatus] = useState<'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled'>('All') + + const onSearchChange = (e: React.ChangeEvent) => { + setSearch(e.target.value) + } + + const filteredResults = useMemo(() => { + if (!data) { + return [] + } + + return data.message.filter((transaction) => { + + if (search && !transaction.description?.toLowerCase().includes(search.toLowerCase())) { + return false + } + + if (typeFilter !== 'All') { + if (typeFilter === 'Debits' && transaction.deposit && transaction.deposit > 0) { + return false + } + if (typeFilter === 'Credits' && transaction.withdrawal && transaction.withdrawal > 0) { + return false + } + } + + if (status !== 'All') { + if (status === 'Reconciled' && transaction.status !== 'Reconciled') { + return false + } + if (status === 'Unreconciled') { + if (transaction.status === 'Reconciled') { + return false + } + // Filter out partially reconciled transactions + if (transaction.allocated_amount && transaction.allocated_amount > 0 && transaction.unallocated_amount !== 0) { + return false + } + } + if (status === 'Partially Reconciled') { + + if (transaction.status === 'Reconciled') { + return false + } + if ((transaction.allocated_amount ?? 0) === 0) { + return false + } + } + + } + + if (amountFilter.value > 0 && transaction.withdrawal !== amountFilter.value && transaction.deposit !== amountFilter.value) { + return false + } + + return true + }) + + + }, [data, search, amountFilter, typeFilter, status]) + + return
+ +
+ + ${bankAccount?.account_name}`, `${formattedFromDate}`, `${formattedToDate}`]) + }} /> + + + +
+ + {error && } + + {data && data.message.length > 0 && } + + {data && data.message.length > 0 ? ( + row.name} + maxHeight="calc(100vh - 200px)" + scrollAreaClassName="min-h-[calc(100vh-200px)]" + emptyState={ + + + + + {_("No bank transactions found")} + {_("There are no transactions in the system for the selected bank account and dates that match the filters.")} + + } + /> + ) : null} + + +
+} + +interface FilterProps { + onSearchChange: (e: React.ChangeEvent) => void + search: string + results: BankTransaction[] + setAmountFilter: (value: { value: number, stringValue?: string | number }) => void + amountFilter: { value: number, stringValue?: string | number } + onTypeFilterChange: (type: string) => void + typeFilter: string + status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled' + setStatus: (status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled') => void +} + + +const Filters = ({ + onSearchChange, + search, + results, + setAmountFilter, + amountFilter, + onTypeFilterChange, + typeFilter, + status, + setStatus, + +}: FilterProps) => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + + const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '') + const currencySymbol = getCurrencySymbol(currency) + const formatInfo = getCurrencyFormatInfo(currency) + const groupSeparator = formatInfo.group_sep || "," + const decimalSeparator = formatInfo.decimal_str || "." + + return
+ + + + + + + + {results?.length} {_(results?.length === 1 ? "result" : "results")} + + + +
+ + { + // If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals. + // When the user eventually types the decimals or blurs out, the value is formatted anyway. + // Otherwise store the float value + // Check if the value ends with a decimal or a decimal with trailing zeroes + const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0') + const newValue = isDecimal ? v : values?.float ?? '' + setAmountFilter({ + value: Number(newValue), + stringValue: newValue + }) + }} + // @ts-expect-error - CurrencyInputProps doesn't have a variant prop but Input does + variant={"outline"} + customInput={Input} + /> +
+
+ + + + + + onTypeFilterChange('All')}> {_("All")} + onTypeFilterChange('Debits')}> {_("Debits")} + onTypeFilterChange('Credits')}> {_("Credits")} + + +
+
+ + + + + + setStatus('All')}>{} {_("All")} + setStatus('Reconciled')}>{} {_("Reconciled")} + setStatus('Unreconciled')}>{} {_("Unreconciled")} + setStatus('Partially Reconciled')}>{} {_("Partially Reconciled")} + + +
+
+} + +export default BankTransactions diff --git a/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx new file mode 100644 index 00000000000..a9996a2ddef --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx @@ -0,0 +1,125 @@ +import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog" +import { useAtom, useAtomValue } from "jotai" +import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { useMemo } from "react" +import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import { toast } from "sonner" +import ErrorBanner from "@/components/ui/error-banner" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { formatCurrency } from "@/lib/numbers" +import { Badge } from "@/components/ui/badge" +import { slug } from "@/lib/frappe" +import SelectedTransactionDetails from "./SelectedTransactionDetails" +import _ from "@/lib/translate" + +const BankTransactionUnreconcileModal = () => { + + const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) + + const onOpenChange = (v: boolean) => { + if (!v) { + setBankRecUnreconcileModal('') + } + } + + return + + + + {_("Undo Transaction Reconciliation")} + + {_("Are you sure you want to unreconcile this transaction?")} + + + + + + + +} + +const BankTransactionUnreconcileModalContent = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + const { mutate } = useSWRConfig() + + const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) + + const { data: transaction, error } = useFrappeGetDoc('Bank Transaction', unreconcileModal) + + const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction') + + const onUnreconcile = (event: React.MouseEvent) => { + call({ + transaction_name: unreconcileModal + }).then(() => { + // Mutate the transactions list, unreconciled transactions list and account closing balance + mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`) + toast.success(_("Transaction Unreconciled")) + setBankRecUnreconcileModal('') + }) + + event.preventDefault() + } + + const vouchersWhichWillBeCancelled = useMemo(() => { + return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created') + }, [transaction]) + + return
+
+ {error && } + {unreconcileError && } + {transaction && } + {_("This transaction has been reconciled with the following document(s):")} + + + + {_("Document")} + {_("Amount")} + {_("Reconciliation Type")} + + + + {transaction?.payment_entries?.map((voucher) => { + return + + + {`${_(voucher.payment_document)}: ${voucher.payment_entry}`} + + + {formatCurrency(voucher.allocated_amount)} + {voucher.reconciliation_type === 'Voucher Created' ? + {_(voucher.reconciliation_type)} : + {_(voucher.reconciliation_type ?? "Matched")}} + + })} + +
+
+ {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && The following documents will be cancelled:} + {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 &&
    + {vouchersWhichWillBeCancelled?.map((voucher) => { + return
  1. {_(voucher.payment_document)}: {voucher.payment_entry}
  2. + })} +
} +
+
+ + {_("Cancel")} + + {_("Unreconcile")} + + +
+} + +export default BankTransactionUnreconcileModal \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/CompanySelector.tsx b/banking/src/components/features/BankReconciliation/CompanySelector.tsx new file mode 100644 index 00000000000..5496ec9f851 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/CompanySelector.tsx @@ -0,0 +1,92 @@ +import { Button } from "@/components/ui/button" +import { selectedCompanyAtom, useCurrentCompany } from "@/hooks/useCurrentCompany" +import { useSetAtom } from "jotai" +import { Building2, Check, ChevronDown } from "lucide-react" +import { useState } from "react" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import _ from "@/lib/translate" +import { selectedBankAccountAtom } from "./bankRecAtoms" + +const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => { + const [open, setOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = window.frappe?.boot?.docs?.filter((doc: Record) => doc.doctype === ":Company").map((company: Record) => company.name) || [] + + const setSelectedCompany = useSetAtom(selectedCompanyAtom) + const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom) + const selectedCompany = useCurrentCompany() + + const handleSelectCompany = (company: string) => { + setSelectedCompany(company) + setSearchQuery("") + setOpen(false) + // Only reset bank account if the company is changed + if (selectedCompany !== company) { + setSelectedBankAccount(null) + onChange?.(company) + } + } + + return ( + + + + + + {options.length > 5 && } + + {_("No company found.")} + + {options.map((option: string) => ( + { + handleSelectCompany(currentValue) + }} + > + {option} + + + ))} + + + + + ) +} + +export default CompanySelector \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx b/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx new file mode 100644 index 00000000000..58940aa6f91 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx @@ -0,0 +1,229 @@ +import { useAtomValue } from "jotai" +import { MissingFiltersBanner } from "./MissingFiltersBanner" +import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" +import { Paragraph } from "@/components/ui/typography" +import type { ColumnDef } from "@tanstack/react-table" +import { useCallback, useMemo } from "react" +import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk" +import { QueryReportReturnType } from "@/types/custom/Reports" +import { formatDate } from "@/lib/date" +import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view" +import { formatCurrency } from "@/lib/numbers" +import { getCompanyCurrency } from "@/lib/company" +import { getErrorMessage, slug } from "@/lib/frappe" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" +import { PartyPopper } from "lucide-react" +import ErrorBanner from "@/components/ui/error-banner" +import _ from "@/lib/translate" +import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty" + +const IncorrectlyClearedEntries = () => { + const companyID = useCurrentCompany() + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + if (!companyID || !bankAccount || !dates) { + const missingFields = [] + if (!companyID) { + missingFields.push('Company') + } + if (!bankAccount) { + missingFields.push('Bank Account') + } + if (!dates) { + missingFields.push('Dates') + } + return + } + + return +} + +interface IncorrectlyClearedEntry { + payment_document: string + payment_entry: string + debit: number + credit: number + posting_date: string, + clearance_date: string, +} + +const IncorrectlyClearedEntriesView = () => { + + const companyID = useCurrentCompany() + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + const filters = useMemo(() => { + return JSON.stringify({ + company: companyID, + account: bankAccount?.account, + report_date: dates.toDate + }) + }, [companyID, bankAccount, dates]) + + const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType }>('frappe.desk.query_report.run', { + report_name: 'Cheques and Deposits Incorrectly cleared', + filters, + ignore_prepared_report: 1, + are_default_filters: false, + }, `Report-Cheques and Deposits Incorrectly cleared-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST') + + const formattedToDate = formatDate(dates.toDate) + + const { call: clearClearingDate } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.clear_clearing_date') + + const onClearClick = useCallback( + (voucher_type: string, voucher_name: string) => { + clearClearingDate({ voucher_type, voucher_name }) + .then(() => { + toast.success(_("Cleared"), { + duration: 1000, + }) + mutate() + }) + .catch((e) => { + toast.error(_("There was an error while performing the action."), { + description: getErrorMessage(e), + duration: 5000, + }) + }) + }, + [clearClearingDate, mutate, _], + ) + + const accountCurrency = useMemo( + () => bankAccount?.account_currency ?? getCompanyCurrency(companyID), + [bankAccount?.account_currency, companyID], + ) + + const incorrectlyClearedColumns = useMemo[]>( + () => [ + { + accessorKey: "payment_document", + header: _("Document Type"), + size: 128, + cell: ({ row }) => _(row.original.payment_document), + }, + { + id: "payment_entry", + header: _("Payment Document"), + size: 160, + meta: { + getTooltipText: (r) => { + const x = r as IncorrectlyClearedEntry + return [x.payment_document, x.payment_entry].filter(Boolean).join(" · ") || undefined + }, + } satisfies ListViewColumnMeta, + cell: ({ row }) => ( + + {row.original.payment_entry} + + ), + }, + { + accessorKey: "debit", + header: _("Debit"), + size: 120, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => formatCurrency(row.original.debit, accountCurrency), + }, + { + accessorKey: "credit", + header: _("Credit"), + size: 120, + meta: { align: "right" } satisfies ListViewColumnMeta, + cell: ({ row }) => formatCurrency(row.original.credit, accountCurrency), + }, + { + accessorKey: "posting_date", + header: _("Posting Date"), + size: 118, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.posting_date), + }, + { + accessorKey: "clearance_date", + header: _("Clearance Date"), + size: 118, + meta: { tabularNums: true } satisfies ListViewColumnMeta, + cell: ({ row }) => formatDate(row.original.clearance_date), + }, + { + id: "actions", + header: _("Actions"), + size: 180, + enableResizing: false, + meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta, + cell: ({ row }) => ( + + ), + }, + ], + [_, accountCurrency, onClearClick], + ) + + return
+ +
+ + clearance date is before the posting date which is incorrect.") + }} /> +
+ {data && data.message.result.length > 0 && + ${formattedToDate}`, `${formattedToDate}`]) + }} /> +
+ {_("You can reset the clearing dates of these entries here.")} +
} +
+
+ + {error && } + + {data && data.message.result.length > 0 && ( +
+

{_("Incorrectly cleared entries as per the report.")}

+ `${row.payment_entry}-${row.posting_date}`} + maxHeight="min(70vh, 640px)" + emptyState={_("No rows to display.")} + /> +
+ )} + + {data && data.message.result.length === 0 && + + + + + + {_("It's all good!")} + {_("There are no entries in the system where the clearance date is before the posting date.")} + + + } + + +
+} + +export default IncorrectlyClearedEntries diff --git a/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx b/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx new file mode 100644 index 00000000000..006668ac50d --- /dev/null +++ b/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx @@ -0,0 +1,949 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { bankRecAmountFilter, bankRecDateAtom, bankRecRecordJournalEntryModalAtom, bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecTransferModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { H4 } from "@/components/ui/typography" +import { useMemo, useRef } from "react" +import { getCompanyCurrency } from "@/lib/company" +import ErrorBanner from "@/components/ui/error-banner" +import { Separator } from "@/components/ui/separator" +import Fuse from 'fuse.js' +import { getSearchResults, LinkedPayment, UnreconciledTransaction, useGetRuleForTransaction, useGetUnreconciledTransactions, useGetVouchersForTransaction, useIsTransactionWithdrawal, useReconcileTransaction, useTransactionSearch } from "./utils" +import { Input } from "@/components/ui/input" +import { AlertCircleIcon, ArrowDownRight, ArrowRightIcon, ArrowRightLeft, ArrowUpRight, BadgeCheck, ChevronDown, DollarSign, Landmark, LandmarkIcon, ListIcon, Loader2, Receipt, ReceiptIcon, Search, User, XCircle, ZapIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import CurrencyInput from 'react-currency-input-field' +import { getCurrencySymbol } from "@/lib/currency" +import { Virtuoso } from 'react-virtuoso' +import { formatDate } from "@/lib/date" +import { Badge } from "@/components/ui/badge" +import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers" +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip" +import { Skeleton } from "@/components/ui/skeleton" +import { slug } from "@/lib/frappe" +import _ from "@/lib/translate" +import TransferModal from "./TransferModal" +import BankEntryModal from "./BankEntryModal" +import RecordPaymentModal from "./RecordPaymentModal" +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import SelectedTransactionsTable from "./SelectedTransactionsTable" +import MatchFilters from "./MatchFilters" +import { useHotkeys } from "react-hotkeys-hook" +import { KeyboardMetaKeyIcon } from "@/components/ui/keyboard-keys" +import { Kbd, KbdGroup } from "@/components/ui/kbd" +import { useFrappeGetCall } from "frappe-react-sdk" +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { Link } from "react-router" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group" + +const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => { + const selectedBank = useAtomValue(selectedBankAccountAtom) + + if (!selectedBank) { + return + + + + + {_("Select a bank account to reconcile")} + + + } + + return <> +
+
+

{_("Unreconciled Transactions")}

+ +
+ +
+

{_("Match or Create")}

+ +
+
+ + + + +} + + +const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + + const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '') + const currencySymbol = getCurrencySymbol(currency) + const formatInfo = getCurrencyFormatInfo(currency) + const groupSeparator = formatInfo.group_sep || "," + const decimalSeparator = formatInfo.decimal_str || "." + + const inputRef = useRef(null) + + const { data: unreconciledTransactions, isLoading, error } = useGetUnreconciledTransactions() + + const [typeFilter, setTypeFilter] = useAtom(bankRecTransactionTypeFilter) + const [amountFilter, setAmountFilter] = useAtom(bankRecAmountFilter) + + const [search, setSearch] = useTransactionSearch() + + const searchIndex = useMemo(() => { + + if (!unreconciledTransactions) { + return null + } + + return new Fuse(unreconciledTransactions.message, { + keys: ['description', 'reference_number'], + threshold: 0.5, + includeScore: true + }) + }, [unreconciledTransactions]) + + const results = useMemo(() => { + + return getSearchResults(searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message) + + }, [searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message]) + + const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(bankAccount?.name || '')) + + const onFilterChange = () => { + setSelectedTransaction([]) + } + + const onSearchChange = (e: React.ChangeEvent) => { + setSearch(e.target.value) + onFilterChange() + } + + const onTypeFilterChange = (type: string) => { + setTypeFilter(type) + onFilterChange() + } + + const onClearFilters = () => { + setSearch('') + if (inputRef.current) { + inputRef.current.value = '' + } + setTypeFilter('All') + setAmountFilter({ value: 0, stringValue: '' }) + onFilterChange() + } + + const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0 + + if (isLoading) { + return + } + + return
+
+ + + + + + + + + {results?.length} {_(results?.length === 1 ? "result" : "results")} + + +
+ + { + // If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals. + // When the user eventually types the decimals or blurs out, the value is formatted anyway. + // Otherwise store the float value + // Check if the value ends with a decimal or a decimal with trailing zeroes + const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0') + const newValue = isDecimal ? v : values?.float ?? '' + const nextAmountFilter = { + value: Number(newValue), + stringValue: newValue + } + const hasAmountFilterChanged = amountFilter.value !== nextAmountFilter.value || amountFilter.stringValue !== nextAmountFilter.stringValue + + setAmountFilter(nextAmountFilter) + + // `onValueChange` also fires on blur; avoid clearing selected transaction unless filter value actually changed. + if (hasAmountFilterChanged) { + onFilterChange() + } + }} + // @ts-expect-error - CurrencyInputProps doesn't have a variant prop but Input does + variant={"outline"} + customInput={Input} + /> +
+
+ + + + + + onTypeFilterChange('All')}> {_("All")} + onTypeFilterChange('Debits')}> {_("Debits")} + onTypeFilterChange('Credits')}> {_("Credits")} + + +
+
+ + {error && } + + + + {results.length === 0 && } + + ( + + )} + style={{ minHeight: Math.max(contentHeight - 80, 400) }} + totalCount={results?.length} + /> + +
+} + +const NoTransactionsFoundBanner = ({ text, description, onClearFilters }: { text: string, description?: string, onClearFilters?: () => void }) => { + + return + + + + + {text} + {description && {description}} + + + {onClearFilters ? : + } + + +} + +const UnreconciledTransactionsLoadingState = () => { + + return
+
+ + + +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+} + +const UnreconciledTransactionItem = ({ transaction }: { transaction: UnreconciledTransaction }) => { + + const selectedBank = useAtomValue(selectedBankAccountAtom) + + const [selectedTransaction, setSelectedTransaction] = useAtom(bankRecSelectedTransactionAtom(selectedBank?.name || '')) + + const { amount, isWithdrawal } = useIsTransactionWithdrawal(transaction) + + const isSelected = selectedTransaction?.some((t) => t.name === transaction.name) + + const currency = transaction.currency ?? selectedBank?.account_currency ?? getCompanyCurrency(selectedBank?.company ?? '') + + const handleSelectTransaction = (event: React.MouseEvent) => { + // If the user is pressing the shift key, add/remove the transaction from the selected transactions + if (event.shiftKey) { + setSelectedTransaction(isSelected ? selectedTransaction.filter((t) => t.name !== transaction.name) : [...selectedTransaction, transaction]) + } else { + setSelectedTransaction([transaction]) + } + } + + return
+
+
+
+
+ {formatDate(transaction.date)} + {transaction.transaction_type && + {transaction.transaction_type}} + {transaction.reference_number && + {_("Ref")}: {transaction.reference_number}} + + {transaction.matched_transaction_rule && + {transaction.matched_transaction_rule}} +
+ {transaction.description} +
+
+ {isWithdrawal ? : } + {amount && amount > 0 && {formatCurrency(amount, currency)}} + {amount !== transaction.unallocated_amount && {formatCurrency(transaction.unallocated_amount, currency)} {_("Unallocated")}} +
+
+
+
+} + + +const VouchersSection = ({ contentHeight }: { contentHeight: number }) => { + + const selectedBank = useAtomValue(selectedBankAccountAtom) + const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || '')) + + + if (selectedTransactions.length === 0) { + return + + + + + {_("Select a transaction to match and reconcile with vouchers")} + + + } + + if (selectedTransactions.length > 1) { + return + } + + return
+ +
+} + +const useKeyboardShortcuts = () => { + const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom) + const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom) + const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + useHotkeys('meta+p', () => { + // + setRecordPaymentModalOpen(true) + }, { + enabled: true, + enableOnFormTags: false, + preventDefault: true + }) + + useHotkeys('meta+b', () => { + // + setRecordJournalEntryModalOpen(true) + }, { + enabled: true, + enableOnFormTags: false, + preventDefault: true + }) + + useHotkeys('meta+i', () => { + // + setTransferModalOpen(true) + }, { + enabled: true, + enableOnFormTags: false, + preventDefault: true + }) + + return { + setTransferModalOpen, + setRecordPaymentModalOpen, + setRecordJournalEntryModalOpen + } +} + +const OptionsForMultipleTransactions = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { + + const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts() + + return
+ + + +
+ {transactions.length} {_(transactions.length === 1 ? _("transaction selected") : _("transactions selected"))} + + {formatCurrency(transactions.reduce((acc, transaction) => acc + (transaction.unallocated_amount ?? 0), 0), transactions[0].currency ?? '')} + +
+
+
+ + + + +
+ +
+ + + + + + {_("Record a journal entry for expenses, income or split transactions")} + + + B + + + + + + + + + {_("Record a payment entry against a customer or supplier")} + + + P + + + + + + + + + + {_("Record an internal transfer to another bank/credit card/cash account")} + + + I + + + + +
+
+
+
+
+
+ +
+} + + +const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => { + + const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts() + + return
+ +
+
+ + + + + + {_("Record a payment entry against a customer or supplier")} + + + P + + + + + + + + + {_("Record a journal entry for expenses, income or split transactions")} + + + B + + + + + + + + + {_("Record an internal transfer to another bank/credit card/cash account")} + + + I + + + +
+ +
+
+ {transaction.matched_transaction_rule && } + +
+} + +const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) => { + + const { data: rule } = useGetRuleForTransaction(transaction) + const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom) + const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom) + const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + if (!rule) { + return null + } + + const getActionIcon = () => { + switch (rule.classify_as) { + case "Bank Entry": + return + case "Payment Entry": + return + case "Transfer": + return + default: + return + } + } + + const getActionStyles = () => { + switch (rule.classify_as) { + case "Bank Entry": + return { + border: "border-outline-blue-3", + bg: "bg-surface-blue-1/50", + text: "text-ink-blue-4", + theme: "blue", + } + case "Payment Entry": + return { + border: "border-outline-green-3", + bg: "bg-surface-green-1/50", + text: "text-ink-green-4", + theme: "green", + } + case "Transfer": + return { + border: "border-outline-violet-3", + bg: "bg-surface-violet-2/50", + text: "text-ink-violet-4", + theme: "violet", + } + default: + return { + border: "border-outline-amber-3", + bg: "bg-surface-amber-1/50", + text: "text-ink-amber-4", + theme: "orange", + } + } + } + + const handleActionClick = () => { + switch (rule.classify_as) { + case "Bank Entry": + setRecordJournalEntryModalOpen(true) + break + case "Payment Entry": + setRecordPaymentModalOpen(true) + break + case "Transfer": + setTransferModalOpen(true) + break + } + } + + const getActionDescription = () => { + switch (rule.classify_as) { + case "Bank Entry": + return _("Create a journal entry for expenses, income or split transactions") + case "Payment Entry": + return _("Record a payment entry against a customer or supplier") + case "Transfer": + return _("Record an internal transfer to another bank/credit card/cash account") + default: + return _("Create a new entry based on the rule") + } + } + + useHotkeys('meta+r', () => { + // + handleActionClick() + }, { + enabled: true, + enableOnFormTags: false, + preventDefault: true + }) + + const styles = getActionStyles() + + return ( + + + +
+
+ {getActionIcon()} +
+
+ {rule.rule_name} + + {rule.rule_description || _("Rule matched based on transaction description and other criteria.")} + +
+
+
+ + {rule.classify_as} + +
+
+
+ +
+
+ + {_("Recommended Action")} +
+ + {_("Priority")} {rule.priority} + +
+ +
+ + {rule.account && ( +
+ {_("Account")}: + {rule.account} +
+ )} + + {rule.party_type && rule.party && ( +
+ {_("Party")}: + {rule.party} ({_(rule.party_type)}) +
+ )} +
+ +
+ +

+ {getActionDescription()} +

+
+
+
+ ) +} + +const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => { + + const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction) + + if (error) { + return + } + + if (isLoading) { + return
+
+ + or + +
+ + + + + + +
+ } + + return
+
+ + or + +
+ {vouchers?.message.length === 0 && + + + + + + {_("No vouchers found for this transaction")} + + } + ( + + )} + style={{ height: contentHeight }} + totalCount={vouchers?.message.length} + /> +
+} + +const VoucherItem = ({ voucher, index }: { voucher: LinkedPayment, index: number }) => { + + const selectedBank = useAtomValue(selectedBankAccountAtom) + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || '')) + + const { amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested } = useMemo(() => { + + const transaction = selectedTransaction?.[0] + + // We need to check if the following details match: + // Amount + // Date + // Reference/Description: Full or partial + // Whether this is suggested or not - depends on the above scores + + const amountMatches = voucher.paid_amount === transaction?.unallocated_amount + const postingDateMatches = voucher.posting_date === transaction?.date + const referenceDateMatches = voucher.reference_date === transaction?.date + const referenceMatchesFull = voucher.reference_no === transaction?.reference_number || voucher.reference_no === transaction?.description + + const referenceMatchesPartial = transaction?.reference_number?.includes(voucher.reference_no) || transaction?.description?.includes(voucher.reference_no) + + + const isSuggested = amountMatches && (postingDateMatches || referenceDateMatches || referenceMatchesPartial) && index === 0 + + return { isSelected: false, amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested: isSuggested } + + }, [voucher, selectedTransaction, index]) + + const { reconcileTransaction, loading } = useReconcileTransaction() + + const onClick = () => { + if (!selectedTransaction) { + return + } + reconcileTransaction(selectedTransaction[0], voucher) + } + + return
+
+ +
+
+
+ {_(voucher.doctype)} + {voucher.name} +
+ {voucher.party && voucher.party_type &&
+ + {_(voucher.party_type)} + {voucher.party} +
} + +
+
+
{_("Amount")}
+
{formatCurrency(voucher.paid_amount, voucher.currency)} {amountMatches ? : }
+
+ +
+
{_("Posted On")}
+
{formatDate(voucher.posting_date)} {postingDateMatches ? : }
+
+ + {voucher.reference_date &&
+
{_("Reference Date")}
+
{formatDate(voucher.reference_date)} {referenceDateMatches ? : }
+
} + +
+ {voucher.reference_no &&
+ + {voucher.reference_no} +    + + + + {referenceMatchesFull ? `${_("Complete Match")}` : referenceMatchesPartial ? `${_("Partial Match")}` : `${_("No Match")}`} + + + {referenceMatchesFull ? `${_("Reference matches the selected transaction")}` : referenceMatchesPartial ? `${_("Reference matches the selected transaction partially")}` : `${_("Reference does not match the selected transaction")}`} + + + +
} +
+
+
+ +
+
+ + {isSuggested &&
+ {_("Suggested")} +
} + +
+
+} + + +const MatchBadge = ({ matchType, label }: { matchType: 'full' | 'partial' | 'none', label: string }) => { + return + + {matchType === 'full' ? : matchType === 'partial' ? + {_("Partial Match")} : + } + + + {label} + + +} + +const OlderUnreconciledTransactionsBanner = () => { + + // A banner to show when there are unreconciled transactions for the given bank account before the current selected date + const [dates, setDates] = useAtom(bankRecDateAtom) + const selectedBank = useAtomValue(selectedBankAccountAtom) + + const { data } = useFrappeGetCall<{ + message: { + count: number, + oldest_date: string + } + }>("erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_older_unreconciled_transactions", { + bank_account: selectedBank?.name, + from_date: dates.fromDate, + }, undefined, { + revalidateOnFocus: false, + }) + + if (data && data.message.count > 0) { + + return + +
+
+ {data.message.count > 1 ? ( + {_("There are {0} unreconciled transactions before {1}.", [data.message.count.toString(), formatDate(dates.fromDate)])} + ) : ( + {_("There is one unreconciled transaction before {0}.", [formatDate(dates.fromDate)])} + )} + + {_("The opening balance might not match your bank statement. Would you like to reconcile them?")} + +
+
+ +
+
+
+ } + + return null + +} + +export default MatchAndReconcile \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/MatchFilters.tsx b/banking/src/components/features/BankReconciliation/MatchFilters.tsx new file mode 100644 index 00000000000..2f91cf0a6b0 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/MatchFilters.tsx @@ -0,0 +1,93 @@ +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import _ from '@/lib/translate' +import { FilterIcon } from 'lucide-react' +import { bankRecMatchFilters } from './bankRecAtoms' +import { useAtom } from 'jotai' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { useFrappeGetCall } from 'frappe-react-sdk' +import { scrub } from '@/lib/frappe' +import { useMemo } from 'react' + +const MatchFilters = () => { + return ( + + + + + + + + + {_("Configure match filters for vouchers")} + + + +
+ + + + +
+
+
+ ) +} + +const MatchFiltersContent = () => { + + const { data } = useFrappeGetCall<{ message: string[] }>("erpnext.accounts.doctype.bank_transaction.bank_transaction.get_doctypes_for_bank_reconciliation", undefined, + "bank_rec_doctypes", { + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false, + } + ) + + const doctypes = useMemo(() => { + const STANDARD_DOCTYPES = ["Payment Entry", "Journal Entry", "Purchase Invoice", "Sales Invoice"] + if (data) { + return data.message.map(doctype => ({ + label: doctype, + id: scrub(doctype), + })) + + } else { + return STANDARD_DOCTYPES.map(doctype => ({ + label: doctype, + id: scrub(doctype), + })) + } + }, [data]) + + return ( +
+ {doctypes.map((doctype) => ( + + ))} +
+ ) +} + +const ToggleSwitch = ({ label, id }: { label: string, id: string }) => { + + const [matchFilters, setMatchFilters] = useAtom(bankRecMatchFilters) + + return
+ { + if (checked) { + setMatchFilters([...matchFilters, id]) + } else { + setMatchFilters(matchFilters.filter(filter => filter !== id)) + } + }} /> + +
+} + +export default MatchFilters \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/MissingFiltersBanner.tsx b/banking/src/components/features/BankReconciliation/MissingFiltersBanner.tsx new file mode 100644 index 00000000000..0510b3412f3 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/MissingFiltersBanner.tsx @@ -0,0 +1,10 @@ +import { Paragraph } from "@/components/ui/typography" +import { cn } from "@/lib/utils" +import { ReactNode } from "react" + + +export const MissingFiltersBanner = ({ text, className }: { text: ReactNode, className?: string }) => { + return
+ {text} +
+} \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx b/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx new file mode 100644 index 00000000000..cebb82cd640 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx @@ -0,0 +1,1301 @@ +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import { bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose, DialogTrigger } from "@/components/ui/dialog" +import _ from "@/lib/translate" +import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils" +import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form" +import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" +import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk" +import { toast } from "sonner" +import ErrorBanner from "@/components/ui/error-banner" +import { Button } from "@/components/ui/button" +import SelectedTransactionDetails from "./SelectedTransactionDetails" +import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements" +import { Form } from "@/components/ui/form" +import { ChangeEvent, useCallback, useContext, useEffect, useMemo, useState } from "react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { AlertCircleIcon, Plus, Trash2 } from "lucide-react" +import { flt, formatCurrency } from "@/lib/numbers" +import { cn } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { PaymentEntry } from "@/types/Accounts/PaymentEntry" +import { H4 } from "@/components/ui/typography" +import { usePaymentEntryCalculations } from "@/hooks/usePaymentEntryCalculations" +import { MissingFiltersBanner } from "./MissingFiltersBanner" +import { formatDate, today } from "@/lib/date" +import { slug } from "@/lib/frappe" +import MarkdownRenderer from "@/components/ui/markdown" +import { Separator } from "@/components/ui/separator" +import { PaymentEntryDeduction } from "@/types/Accounts/PaymentEntryDeduction" +import { TableLoader } from "@/components/ui/loaders" +import SelectedTransactionsTable from "./SelectedTransactionsTable" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" +import { Label } from "@/components/ui/label" +import { FileDropzone } from "@/components/ui/file-dropzone" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import FileUploadBanner from "@/components/common/FileUploadBanner" +import { useHotkeys } from "react-hotkeys-hook" + +const RecordPaymentModal = () => { + + const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom) + + return ( + + + + {_("Record Payment")} + + {_("Record a payment entry against a customer or supplier")} + + + + + + ) +} + + +const RecordPaymentModalContent = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + + if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { + return
+ {_("No transaction selected")} +
+ } + + if (selectedTransaction.length === 1) { + return + } + + return + +} + +const BulkPaymentEntryForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { + + + const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) + + const form = useForm<{ + party_type: PaymentEntry['party_type'], + party: PaymentEntry['party'], + party_name: PaymentEntry['party_name'], + /** GL account that's paid from or paid to */ + account: string + mode_of_payment: PaymentEntry['mode_of_payment'] + }>() + + const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_payment_entry_and_reconcile') + + const onReconcile = useRefreshUnreconciledTransactions() + + const addToActionLog = useUpdateActionLog() + + const onSubmit = (data: { party_type: PaymentEntry['party_type'], party: PaymentEntry['party'], account: string, mode_of_payment: PaymentEntry['mode_of_payment'] }) => { + + createPaymentEntry({ + bank_transaction_names: transactions.map((transaction) => transaction.name), + party_type: data.party_type, + party: data.party, + account: data.account + }).then(({ message }) => { + + addToActionLog({ + type: 'payment', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: item.payment_entry.name, + reference_no: item.payment_entry.reference_no, + reference_date: item.payment_entry.reference_date, + posting_date: item.payment_entry.posting_date, + party_type: item.payment_entry.party_type, + party: item.payment_entry.party, + doc: item.payment_entry, + } + })), + bulkCommonData: { + party_type: data.party_type, + party: data.party, + account: data.account, + } + }) + + toast.success(_("Payment Recorded"), { + duration: 4000, + closeButton: true, + }) + onReconcile(transactions[transactions.length - 1]) + setIsOpen(false) + }) + } + + const party_type = useWatch({ control: form.control, name: 'party_type' }) + + const party_name = useWatch({ control: form.control, name: 'party_name' }) + + const party = useWatch({ control: form.control, name: 'party' }) + + const { call } = useContext(FrappeContext) as FrappeConfig + + const currentCompany = useCurrentCompany() + + const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') + + const onPartyChange = (event: ChangeEvent) => { + // Fetch the party and account + if (event.target.value) { + call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { + company: company, + party_type: party_type, + party: event.target.value, + date: today() + }).then((res) => { + form.setValue('party_name', res.message.party_name) + form.setValue('account', res.message.party_account) + }) + } else { + // Clear the party and account + form.setValue('party_name', '') + form.setValue('account', '') + } + + } + + return
+ +
+ + {error && } + + + +
+
+ +
+
+ {party_type ? : + } + + +
+ +
+ { + if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { + return acc.account_type === 'Payable' + } else if (party_type === 'Customer') { + return acc.account_type === 'Receivable' + } + return true + }} + /> +
+ +
+ +
+ +
+ + + + + + + + +
+
+ + +} + +const PaymentEntryForm = ({ selectedTransaction, selectedBankAccount }: { selectedTransaction: UnreconciledTransaction, selectedBankAccount: SelectedBank }) => { + + const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const form = useForm({ + defaultValues: { + payment_type: isWithdrawal ? 'Pay' : 'Receive', + bank_account: selectedTransaction.bank_account, + company: selectedTransaction?.company, + // If the money is paid, it's usually to a supplier. If it's received, it's usually from a customer + party_type: rule?.party_type ?? (isWithdrawal ? 'Supplier' : 'Customer'), + party: rule?.party ?? '', + // If the transaction is a withdrawal, set the paid from to the selected bank account + paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // If the transaction is a deposit, set the paid to to the selected bank account + paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // Set the amount to the amount of the selected transaction + paid_amount: selectedTransaction.unallocated_amount, + base_paid_amount: selectedTransaction.unallocated_amount, + received_amount: selectedTransaction.unallocated_amount, + base_received_amount: selectedTransaction.unallocated_amount, + reference_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + target_exchange_rate: 1, + source_exchange_rate: 1, + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const setUnpaidInvoiceOpen = useSetAtom(isUnpaidInvoicesButtonOpen) + + useEffect(() => { + if (rule && rule.party && rule.party_type && rule.account) { + setUnpaidInvoiceOpen(true) + } + + }, [rule, setUnpaidInvoiceOpen]) + + const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_and_reconcile') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + + const [files, setFiles] = useState([]) + + const onSubmit = (data: PaymentEntry) => { + + createPaymentEntry({ + bank_transaction_name: selectedTransaction.name, + payment_entry_doc: { + ...data, + custom_remarks: data.remarks ? true : false + } + }).then(async ({ message }) => { + addToActionLog({ + type: 'payment', + timestamp: (new Date()).getTime(), + isBulk: false, + items: [ + { + bankTransaction: message.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: message.payment_entry.name, + reference_no: message.payment_entry.reference_no, + reference_date: message.payment_entry.reference_date, + posting_date: message.payment_entry.posting_date, + doc: message.payment_entry, + } + } + ] + }) + toast.success(_("Payment Entry Created"), { + duration: 4000, + closeButton: true, + action: { + label: _("Undo"), + onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) + }, + actionButtonStyle: { + backgroundColor: "rgb(0, 138, 46)" + } + }) + + if (files.length > 0) { + setIsUploading(true) + + const uploadPromises = files.map(f => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Payment Entry", + docname: message.payment_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + + setUploadProgress((currentProgress) => { + //If there are multiple files, we need to add the progress to the current progress + return currentProgress + ((progress?.progress ?? 0) / files.length) + }) + + }) + }) + + return Promise.all(uploadPromises).then(() => { + setUploadProgress(0) + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + + }).then(() => { + setUploadProgress(0) + setIsUploading(false) + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ +
+

{isWithdrawal ? _("Paid to") : _("Received from")}

+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+ + + + + + +
+
+ +} + +const isUnpaidInvoicesButtonOpen = atom(false) + +const PartyField = () => { + + const { control, setValue } = useFormContext() + + const party_type = useWatch({ + control, + name: `party_type` + }) + + const { call } = useContext(FrappeContext) as FrappeConfig + + const company = useWatch({ control, name: 'company' }) + + const party_name = useWatch({ control, name: 'party_name' }) + + const type = useWatch({ control, name: 'payment_type' }) + + const party = useWatch({ control, name: 'party' }) + + const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) + + const onChange = (event: ChangeEvent) => { + // Fetch the party and account + if (event.target.value) { + call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { + company: company, + party_type: party_type, + party: event.target.value, + date: today() + }).then((res) => { + setValue('party_name', res.message.party_name) + if (type === 'Pay') { + setValue('paid_to', res.message.party_account) + } else { + setValue('paid_from', res.message.party_account) + } + setIsOpen(true) + }) + } else { + // Clear the party and account + setValue('party_name', '') + if (type === 'Pay') { + setValue('paid_to', '') + } else { + setValue('paid_from', '') + } + } + + } + + if (!party_type) { + return + } + + return +} + + +const AccountDropdown = ({ isWithdrawal }: { isWithdrawal: boolean }) => { + + // If it's a withdrawal, then we need to show the "Paid to" account + // If it's a deposit, then we need to show the "Paid from" account + + const { control, setValue } = useFormContext() + + const party_type = useWatch({ control, name: 'party_type' }) + + const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) + + const accountTypes: string[] | undefined = useMemo(() => { + if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { + return ['Payable'] + } else if (party_type === 'Customer') { + return ['Receivable'] + } + return undefined + }, [party_type]) + + const onAccountChange = (event: ChangeEvent) => { + if (event.target.value) { + setValue('unallocated_amount', 0) + setValue('total_allocated_amount', 0) + setValue('difference_amount', 0) + setValue('references', []) + setIsOpen(true) + } + } + + + if (isWithdrawal) { + return + + } else { + return + } + +} + + +const InvoicesSection = ({ currency }: { currency: string }) => { + + const { setTotalAllocatedAmount } = usePaymentEntryCalculations() + + const { control } = useFormContext() + const { fields, remove } = useFieldArray({ + control, + name: 'references' + }) + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onRemove = useCallback(() => { + remove(selectedRows) + setSelectedRows([]) + }, [remove, selectedRows]) + + return
+
+

{_("Invoices")}

+ +
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Reference Document")} + {_("Invoice No")} + {_("Due Date")} + {_("Grand Total")} + {_("Outstanding")} + {_("Allocated")} + + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + /> + + + + {field.reference_doctype}: {field.reference_name} + + + {field.bill_no ?? "-"} + + + {formatDate(field.due_date)} + + + {formatCurrency(field.total_amount, currency)} + + + {formatCurrency(field.outstanding_amount, currency)} + + + setTotalAllocatedAmount() + }} + hideLabel + currency={currency} + /> + + + + + + ))} + +
+
+
+ {selectedRows.length > 0 &&
+ +
} +
+ +
+
+ +} + +const DifferenceButton = ({ index, currency }: { index: number, currency: string }) => { + + const { setTotalAllocatedAmount } = usePaymentEntryCalculations() + + const { control, setValue } = useFormContext() + + const outstandingAmount = useWatch({ + control, + name: `references.${index}.outstanding_amount` + }) ?? 0 + + const allocatedAmount = useWatch({ + control, + name: `references.${index}.allocated_amount` + }) ?? 0 + + const difference = flt(outstandingAmount - allocatedAmount, 2) + + const onPayInFull = useCallback(() => { + setValue(`references.${index}.allocated_amount`, outstandingAmount, { shouldDirty: true }) + setTotalAllocatedAmount() + }, [outstandingAmount, index, setValue, setTotalAllocatedAmount]) + + if (difference !== 0) { + + return + + + + + {_("The invoice is not fully allocated as there is a difference of {0}.", [formatCurrency(difference, currency) ?? ''])} +
+ {_("Click to pay in full.")} +
+
+ + } + + return null +} + +const Summary = ({ currency }: { currency: string }) => { + + const { control, setValue, getValues } = useFormContext() + + const { setUnallocatedAmount } = usePaymentEntryCalculations() + + const amount = useWatch({ + control, + name: 'paid_amount' + }) + + const unallocatedAmount = useWatch({ + control, + name: 'unallocated_amount' + }) + + const allocatedAmount = useWatch({ + control, + name: 'total_allocated_amount' + }) + + const differenceAmount = useWatch({ + control, + name: 'difference_amount' + }) + + const onAddRow = useCallback((amount?: number) => { + if (amount) { + const deductions = getValues('deductions') ?? [] + + setValue('deductions', [...deductions, { + amount: amount, + account: '', + cost_center: getCompanyCostCenter(getValues('company')), + description: '' + } as PaymentEntryDeduction]) + + setUnallocatedAmount() + } + }, [setUnallocatedAmount, getValues, setValue]) + + const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { + return {children} + } + + return
+
+ {_("Total Amount")} + {formatCurrency(amount, currency)} +
+
+ {_("Allocated")} + {formatCurrency(allocatedAmount, currency)} +
+ + {(unallocatedAmount && unallocatedAmount !== 0) ?
+ {_("Unallocated")} + + + + + + {_("Add a charge to the payment entry with the unallocated amount")} + + + + +
: null} + + {(differenceAmount && differenceAmount !== 0) ?
+ {_("Difference")} + + + + + + {_("Add a charge to the payment entry with the difference amount")} + + + + +
: null} + +
+} +const GetUnpaidInvoicesButton = () => { + + const [isOpen, setIsOpen] = useAtom(isUnpaidInvoicesButtonOpen) + + const { control } = useFormContext() + + const partyType = useWatch({ control, name: 'party_type' }) + const party = useWatch({ control, name: 'party' }) + const partyName = useWatch({ control, name: 'party_name' }) + const amount = useWatch({ control, name: 'paid_amount' }) + + return <> + + + {partyType && party && + + } + + + Select Invoices + Unpaid invoices from {partyName} for {formatCurrency(amount)}. + + setIsOpen(false)} /> + + + +} + +interface OutstandingInvoice { + voucher_type: string + voucher_no: string + bill_no?: string + due_date: string + invoice_amount: number + outstanding_amount: number, + payment_term?: string, + payment_term_outstanding?: string, + account?: string, + allocated_amount?: number, +} +const FetchInvoicesModal = ({ onClose }: { onClose: () => void }) => { + + const { getValues, setValue } = useFormContext() + + const { allocatePartyAmount } = usePaymentEntryCalculations() + + const { data, isLoading, error } = useFrappeGetCall<{ + message: OutstandingInvoice[], + _server_messages?: string + }>('erpnext.accounts.doctype.payment_entry.payment_entry.get_outstanding_reference_documents', { + args: { + company: getValues('company'), + posting_date: getValues('posting_date'), + party_type: getValues('party_type'), + party: getValues('party'), + party_account: getValues('payment_type') === 'Pay' ? getValues('paid_to') : getValues('paid_from'), + get_outstanding_invoices: true, + allocate_payment_amount: 1 + } + }) + + const message = useMemo(() => { + if (data && data._server_messages) { + const message = JSON.parse(JSON.parse(data._server_messages)[0]) + + return message.message + } + return '' + }, [data]) + + const [selectedInvoices, setSelectedInvoices] = useState([]) + + const onSelectRow = (row: OutstandingInvoice) => { + if (selectedInvoices.includes(row)) { + setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== row)) + } else { + setSelectedInvoices([...selectedInvoices, row]) + } + } + + const { call: allocateAmountToReferences, loading: allocateAmountToReferencesLoading, error: allocateAmountToReferencesError } = useFrappePostCall('run_doc_method') + + const onSelect = () => { + + allocateAmountToReferences({ + args: { + paid_amount: getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount"), + allocate_payment_amount: 1, + paid_amount_change: false + }, + method: 'allocate_amount_to_references', + docs: { + doctype: 'Payment Entry', + ...getValues(), + name: "new-payment-entry-1", + __unsaved: 1, + __islocal: 1, + references: selectedInvoices.map((ref: OutstandingInvoice) => ({ + reference_doctype: ref.voucher_type, + reference_name: ref.voucher_no, + due_date: ref.due_date, + total_amount: ref.invoice_amount, + outstanding_amount: ref.outstanding_amount, + bill_no: ref.bill_no, + payment_term: ref.payment_term, + payment_term_outstanding: ref.payment_term_outstanding, + allocated_amount: ref.allocated_amount, + account: ref.account, + exchange_rate: 1, + })) + } + }).then((res) => { + const doc = res.docs[0] + setValue('references', doc.references) + setValue('unallocated_amount', doc.unallocated_amount) + setValue('total_allocated_amount', doc.total_allocated_amount) + setValue('difference_amount', doc.difference_amount) + + allocatePartyAmount(getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount")) + + onClose() + }) + } + return
+ {isLoading ? : null} + {error && } + {error && } + {message ? } /> : null} + + {data?.message && data?.message?.length > 0 ? + + + + { + if (checked) { + setSelectedInvoices(data?.message) + } else { + setSelectedInvoices([]) + } + }} /> + + + Type + + + Name + + + Invoice No + + + Due Date + + + Grand Total + + + Outstanding + + + + + {data.message.map((ref) => ( + { + const target = e.target as HTMLElement + // Do not select the checkbox if the user clicks on the checkbox or the link + if (target.tagName !== 'INPUT' && !target.className.includes('chakra-checkbox') && !target.className.includes('chakra-link')) { + onSelectRow(ref) + } + }} + className="cursor-pointer"> + + { + if (checked) { + setSelectedInvoices([...selectedInvoices, ref]) + } else { + setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== ref)) + } + }} + /> + + + {ref.voucher_type} + + + {ref.voucher_no} + + + {ref.bill_no ?? "-"} + + + {formatDate(ref.due_date)} + + + {formatCurrency(ref.invoice_amount)} + + + {formatCurrency(ref.outstanding_amount)} + + + ))} + +
: null} +
+
+ Invoices: {selectedInvoices.length} / + Total: {formatCurrency(selectedInvoices.reduce((acc, invoice) => acc + invoice.outstanding_amount, 0))} +
+ + + + + + +
+ +
+} + + + +const OtherChargesSection = ({ currency }: { currency: string }) => { + + const { setTotalAllocatedAmount } = usePaymentEntryCalculations() + const { getValues, control } = useFormContext() + + const { fields, append, remove } = useFieldArray({ + control: control, + name: 'deductions' + }) + + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onRemove = useCallback(() => { + remove(selectedRows) + setSelectedRows([]) + setTotalAllocatedAmount() + }, [remove, selectedRows, setTotalAllocatedAmount]) + + const onAdd = () => { + + append({ + account: '', + cost_center: getCompanyCostCenter(getValues('company')), + description: '', + amount: 0 + } as PaymentEntryDeduction) + + + } + + return
+
+

Other Charges / Deductions

+ +
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Account")} * + {_("Cost Center")} * + {_("Description")} + {_("Amount")} * + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + /> + + + + + + + + + + + + + { + setTotalAllocatedAmount() + } + }} + /> + + + ))} + +
+
+
+
+ +
+ {selectedRows.length > 0 &&
+ +
} +
+
+
+} + +const TotalDeductions = ({ currency }: { currency: string }) => { + + const { control } = useFormContext() + + const total_deductions = useWatch({ control, name: 'deductions' })?.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0) ?? 0 + + return ({formatCurrency(total_deductions, currency)}) +} +export default RecordPaymentModal \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/Rules/CreateNewRule.tsx b/banking/src/components/features/BankReconciliation/Rules/CreateNewRule.tsx new file mode 100644 index 00000000000..1f2d29c630e --- /dev/null +++ b/banking/src/components/features/BankReconciliation/Rules/CreateNewRule.tsx @@ -0,0 +1,89 @@ +import { Button } from "@/components/ui/button" +import ErrorBanner from "@/components/ui/error-banner" +import { Form } from "@/components/ui/form" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" +import _ from "@/lib/translate" +import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule" +import { useFrappeCreateDoc } from "frappe-react-sdk" +import { toast } from "sonner" +import { RuleForm } from "./RuleForm" +import { useForm } from "react-hook-form" +import { SettingsPanelHeader, SettingsPanelDescription, SettingsPanelTitle, SettingsPanelContent } from "@/components/ui/settings-dialog" +import { useHotkeys } from "react-hotkeys-hook" + +type Props = { + onCreate: VoidFunction +} + +const CreateNewRule = ({ onCreate }: Props) => { + + const currentCompany = useCurrentCompany() + + const form = useForm({ + defaultValues: { + rule_name: "", + company: currentCompany, + rule_description: "", + transaction_type: "Any", + classify_as: 'Bank Entry', + bank_entry_type: "Single Account", + description_rules: [{ + check: "Contains", + }] + } + }) + + const { createDoc, loading, error } = useFrappeCreateDoc() + + const onSubmit = (data: BankTransactionRule) => { + createDoc("Bank Transaction Rule", data) + .then(() => { + toast.success(_("Rule created successfully")) + onCreate() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + return ( + <> + + + + + } + > + + {_("New Rule")} + + + {_("Create a new rule to automatically classify transactions.")} + + + +
+ +
+ {error && } + +
+
+ +
+ + + ) +} + +export default CreateNewRule \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/Rules/EditRule.tsx b/banking/src/components/features/BankReconciliation/Rules/EditRule.tsx new file mode 100644 index 00000000000..96f749fe016 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/Rules/EditRule.tsx @@ -0,0 +1,101 @@ +import { Button } from "@/components/ui/button" +import ErrorBanner from "@/components/ui/error-banner" +import { Form } from "@/components/ui/form" +import _ from "@/lib/translate" +import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule" +import { FrappeError, useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk" +import { toast } from "sonner" +import { RuleForm } from "./RuleForm" +import { useForm } from "react-hook-form" +import { Skeleton } from "@/components/ui/skeleton" +import { SettingsPanelContent, SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle } from "@/components/ui/settings-dialog" +import { useHotkeys } from "react-hotkeys-hook" + +type Props = { + onClose: VoidFunction, + ruleID: string +} + +const EditRule = ({ onClose, ruleID }: Props) => { + + const { data: rule, isValidating, error, mutate } = useFrappeGetDoc("Bank Transaction Rule", ruleID, undefined, { + revalidateOnMount: true + }) + + const { updateDoc, loading, error: updateError } = useFrappeUpdateDoc() + + const onSubmit = (data: BankTransactionRule) => { + updateDoc("Bank Transaction Rule", ruleID, data) + .then(() => { + toast.success(_("Rule updated.")) + mutate() + onClose() + }) + } + + return <> + + + + + } + > + + {rule?.rule_name} + + + {_("Edit this rule")} + + + + {isValidating &&
+ + + + + +
} + + {error &&
+ +
} + {rule && } +
+ + + +} + +const EditRuleForm = ({ rule, onSubmit, error }: { rule: BankTransactionRule, onSubmit: (data: BankTransactionRule) => void, error?: FrappeError | null }) => { + + const form = useForm({ + defaultValues: { + ...rule, + } + }) + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + return ( +
+ +
+ {error && } + +
+
+ + ) +} + +export default EditRule \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx b/banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx new file mode 100644 index 00000000000..1655de9ec83 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx @@ -0,0 +1,799 @@ +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog" +import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form" +import { AccountFormField, CurrencyFormField, DataField, LinkFormField, PartyTypeFormField, SelectFormField, SmallTextField } from "@/components/ui/form-elements" +import { Label } from "@/components/ui/label" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { SelectItem } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { H4, Paragraph } from "@/components/ui/typography" +import { today } from "@/lib/date" +import _ from "@/lib/translate" +import { cn } from "@/lib/utils" +import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule" +import { BankTransactionRuleAccounts } from "@/types/Accounts/BankTransactionRuleAccounts" +import { FrappeConfig, FrappeContext } from "frappe-react-sdk" +import { ArrowDownRight, ArrowDownUp, ArrowRightLeftIcon, ArrowUpRight, LandmarkIcon, Plus, PlusCircleIcon, ReceiptIcon, Settings, Trash2 } from "lucide-react" +import { ChangeEvent, useCallback, useContext, useMemo, useRef, useState } from "react" +import { useFieldArray, useFormContext, useWatch } from "react-hook-form" + +export const RuleForm = ({ isEdit = false }: { isEdit?: boolean }) => { + + return
+ + + + + + + + +
+ + + +
+ + + + + + +
+} + +const CompanySelector = () => { + + const { setValue } = useFormContext() + + return { + setValue('account', '') + } + }} + /> + +} + +/** Component to render a radio group as a toggle group with options for All, Withdrawal, Deposit */ +const TransactionTypeSelector = () => { + + const { control } = useFormContext() + + return ( + ( + + + {_("Transaction Type")}* + + + + + + + + + + {_("All")} + + + + + + + + + {_("Withdrawal")} + + + + + + + + + {_("Deposit")} + + + + + + )} + /> + ) +} + +const DescriptionRules = () => { + + const { control } = useFormContext() + + const { fields, append, remove } = useFieldArray({ + control, + name: "description_rules" + }) + + const addRow = () => { + // @ts-expect-error - we don't need all fields here + append({ check: "Contains" }) + } + + return ( +
+ {_("Rules to match against the transaction description")} * + {fields.map((field, index) => ( +
+
+ + {_("Contains")} + {_("Starts with")} + {_("Ends with")} + {_("Regex")} + +
+
+ +
+
+ +
+
+ ))} + +
+ +
+ +
+ ) +} + +const RuleAction = () => { + + const { control } = useFormContext() + + const classify_as = useWatch({ control, name: "classify_as" }) + const party_type = useWatch({ control, name: "party_type" }) + const bank_entry_type = useWatch({ control, name: "bank_entry_type" }) + + const accountType = useMemo(() => { + if (classify_as === "Payment Entry") { + return party_type === "Supplier" ? ["Payable"] : ["Receivable"] + } + + if (classify_as === "Transfer") { + return ["Bank", "Cash", "Temporary"] + } + + return undefined + + }, [classify_as, party_type]) + + return ( +
+

{_("If rule matches, then:")}

+ + + {_("Bank Entry")} + {_("Payment Entry")} + {_("Transfer")} + + + {classify_as === "Bank Entry" && ( + {_("Single Account")} + {_("Multiple Accounts (Journal Template)")} + )} + + + {classify_as === "Payment Entry" && ( +
+
+ +
+
+ +
+
+ )} + + {(((bank_entry_type === "Single Account" || !bank_entry_type) && classify_as === "Bank Entry") || classify_as !== "Bank Entry") && ()} + + {bank_entry_type === "Multiple Accounts" && classify_as === "Bank Entry" && } +
+ ) +} + +const PartyField = () => { + + const { control, setValue } = useFormContext() + + const party_type = useWatch({ + control, + name: `party_type` + }) + + const { call } = useContext(FrappeContext) as FrappeConfig + + const company = useWatch({ control, name: 'company' }) + + const onChange = (event: ChangeEvent) => { + // Fetch the party and account + if (event.target.value) { + call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { + company: company, + party_type: party_type, + party: event.target.value, + date: today() + }).then((res) => { + setValue('account', res.message.party_account) + }) + } else { + // Clear the account + setValue('account', '') + } + + } + + if (!party_type) { + return + } + + return +} + +const MultipleAccountsSelection = () => { + + + const { control } = useFormContext() + + const accounts = useWatch({ + control, + name: 'accounts' + }) ?? [] + + const [isConfigureAccountsModalOpen, setIsConfigureAccountsModalOpen] = useState(false) + + + + return
+
+ + +
+ + + + + + {_("Account")} + {_("Debit")} + {_("Credit")} + + + + {accounts.length === 0 && ( + + +
+ {_("No accounts configured")} + +
+
+
+ )} + {accounts.map((account, index) => ( + + {account.account} + {index === accounts.length - 1 ? + + + {_("This is auto computed to balance the journal entry.")} + + + {_("Based on the above entries, the balance amount (debit or credit) will be set for the last row to balance the journal entry.")} + + + : <> + + + } + + ))} +
+
+ + setIsConfigureAccountsModalOpen(false)} /> +
+} + +const AmountFormulaRenderer = ({ value }: { value?: string }) => { + + // If it's a string and cannot be a number, then show it as a formula + + if (isNaN(Number(value))) { + + let calculatedValue = ""; + + try { + calculatedValue = window.eval(`const transaction_amount = 200; ${value}`); + } catch (error: unknown) { + console.error(error); + calculatedValue = "Error"; + } + + const isComputationValid = !isNaN(Number(calculatedValue)) && calculatedValue !== undefined && calculatedValue !== null; + + return + + {value} + + +

+ {isComputationValid ? _("This is a formula based value.") : _("This is not a valid formula. Check the variable used in the formula.")} +

+ {_("Example: If the transaction amount is 200, then this will be calculated as {} = {}", [value ?? "", calculatedValue])} +

+
+
+ } + + return {value} +} + +const ConfigureAccountsModal = ({ open, onClose }: { open: boolean, onClose: () => void }) => { + + + return + + + + +} + +const ConfigureAccountsModalContent = () => { + + const { control, getValues, setValue } = useFormContext() + + const { call } = useContext(FrappeContext) as FrappeConfig + + // const costCenterMapRef = useRef>({}) + + const partyMapRef = useRef>({}) + + const onPartyChange = (value: string, index: number) => { + // Get the account for the party type + if (value) { + if (partyMapRef.current[value]) { + setValue(`accounts.${index}.account`, partyMapRef.current[value]) + } else { + call.get('erpnext.accounts.party.get_party_account', { + party: value, + party_type: getValues(`accounts.${index}.party_type`), + company: company + }).then((result: { message: string }) => { + setValue(`accounts.${index}.account`, result.message) + partyMapRef.current[value] = result.message + }) + } + } else { + setValue(`accounts.${index}.account`, '') + } + } + + const transaction_type = useWatch({ + name: 'transaction_type', + control, + }) + + const { fields, append, remove } = useFieldArray({ + control, + name: 'accounts' + }) + + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onAdd = () => { + append({ + party_type: '', + party: '', + account: '', + debit: '', + credit: '', + user_remark: '' + } as BankTransactionRuleAccounts, { + focusName: `accounts.${fields.length}.account` + }) + } + + const onRemove = useCallback(() => { + remove(selectedRows) + setSelectedRows([]) + }, [remove, selectedRows]) + + const isWithdrawal = transaction_type === 'Withdrawal' + + const company = useWatch({ + name: 'company', + control, + }) + + return <> + + {_("Configure Accounts for Bank Entry")} + {_("Add all accounts that you want to split the transaction into.")} + +
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Party")} + {_("Account")} * + {/* {_("Cost Center")} */} + {_("Remarks")} + {_("Debit")} + {_("Credit")} + + + + + + + + + + + + Bank GL Account + + + + + + + + {transaction_type === "Withdrawal" || transaction_type === "Any" ? _("Will be auto-populated") : ""} + + + + + {transaction_type === "Deposit" || transaction_type === "Any" ? _("Will be auto-populated") : ""} + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + /> + + + +
+ + +
+ +
+ + { + // onAccountChange(event.target.value, index) + // } + }} + buttonClassName="min-w-64" + isRequired + hideLabel + /> + + {/* + + */} + + + + + + + + + +
+ ))} +
+
+
+
+
+ +
+ {selectedRows.length > 0 &&
+ +
} +
+
+
+ +
+ +
+

{_("Help")}

+ + {(_("You can set up the rule to split the transaction across multiple accounts."))} +
{_("You can also add credit or debit values to pre-fill - these support both static values (like 200) or formulas (like transaction_amount * 0.25).")} +
+
+ {_("Example")}: +
+ + transaction_amount * 0.25 + +
+ + {_("In this case, the amount will be calculated as 25% of the transaction amount. If the transaction amount is 200, then this will be calculated as 200 * 0.25 = 50.")} + +
+
+ + +
+ +} + + +const PartyRowField = ({ index, onChange }: { index: number, onChange: (value: string, index: number) => void }) => { + + const { control } = useFormContext() + + const party_type = useWatch({ + control, + name: `accounts.${index}.party_type` + }) + + if (!party_type) { + return + } + + return { + onChange(event.target.value, index) + }, + }} + hideLabel + buttonClassName="rounded-s-none border-s-0 min-w-64" + doctype={party_type} + + /> +} diff --git a/banking/src/components/features/BankReconciliation/SelectedTransactionDetails.tsx b/banking/src/components/features/BankReconciliation/SelectedTransactionDetails.tsx new file mode 100644 index 00000000000..53ffba910a5 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/SelectedTransactionDetails.tsx @@ -0,0 +1,73 @@ +import { useMemo } from 'react' +import { ArrowDownRight, ArrowUpRight, Calendar } from 'lucide-react' +import { formatCurrency } from '@/lib/numbers' +import { formatDate } from '@/lib/date' +import { UnreconciledTransaction, useGetBankAccounts } from './utils' +import { getCompanyCurrency } from '@/lib/company' +import { Card, CardContent } from '@/components/ui/card' +import { cn } from '@/lib/utils' +import _ from '@/lib/translate' +import BankLogo from '@/components/common/BankLogo' + +type Props = { + transaction: UnreconciledTransaction, + showAccount?: boolean, + account?: string +} + +const SelectedTransactionDetails = ({ transaction, showAccount = false, account }: Props) => { + + const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0 + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + if (transaction.bank_account) { + return banks?.find((bank) => bank.name === transaction.bank_account) + } + return null + }, [transaction.bank_account, banks]) + + const amount = transaction.withdrawal ? transaction.withdrawal : transaction.deposit + + const currency = transaction.currency || getCompanyCurrency(transaction.company ?? '') + + return ( + + +
+
+
+
+ + {transaction.bank_account} +
+
+ + {formatDate(transaction.date, 'Do MMM YYYY')} +
+
+
+
+ {isWithdrawal ? : } + {isWithdrawal ? _('Spent') : _('Received')} +
+ {formatCurrency(amount, currency)} + {transaction.unallocated_amount && transaction.unallocated_amount !== amount ? {_("Unallocated")}: {formatCurrency(transaction.unallocated_amount)} : null} +
+
+
+ {transaction.description} + {transaction.reference_number ? {_("Ref")}: {transaction.reference_number} : null} + {showAccount && account ? {_("GL Account")}: {account} : null} +
+ +
+
+
+ ) +} + +export default SelectedTransactionDetails \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/SelectedTransactionsTable.tsx b/banking/src/components/features/BankReconciliation/SelectedTransactionsTable.tsx new file mode 100644 index 00000000000..6334305c080 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/SelectedTransactionsTable.tsx @@ -0,0 +1,47 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import _ from '@/lib/translate' +import { useAtomValue } from 'jotai' +import { bankRecSelectedTransactionAtom, selectedBankAccountAtom } from './bankRecAtoms' +import { formatDate } from '@/lib/date' +import { formatCurrency } from '@/lib/numbers' +import { ArrowDownRight, ArrowUpRight } from 'lucide-react' + +const SelectedTransactionsTable = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const transactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + return ( + + + + + {_("Date")} + + + {_("Description")} + + + {_("Amount")} + + + + + {transactions.map((transaction) => ( + + {formatDate(transaction.date)} + {transaction.description} + + {transaction.withdrawal && transaction.withdrawal > 0 ? : } + + {formatCurrency(transaction.unallocated_amount, transaction.currency ?? '')} + + + + ))} + +
+ ) +} + +export default SelectedTransactionsTable \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/TransferModal.tsx b/banking/src/components/features/BankReconciliation/TransferModal.tsx new file mode 100644 index 00000000000..fb824dbc6f7 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/TransferModal.tsx @@ -0,0 +1,555 @@ +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms' +import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import _ from '@/lib/translate' +import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils' +import { Button } from '@/components/ui/button' +import SelectedTransactionDetails from './SelectedTransactionDetails' +import { PaymentEntry } from '@/types/Accounts/PaymentEntry' +import { useForm, useFormContext, useWatch } from 'react-hook-form' +import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk' +import { toast } from 'sonner' +import ErrorBanner from '@/components/ui/error-banner' +import { H4 } from '@/components/ui/typography' +import { cn } from '@/lib/utils' +import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react' +import { Separator } from '@/components/ui/separator' +import { Form } from '@/components/ui/form' +import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements' +import SelectedTransactionsTable from './SelectedTransactionsTable' +import { useCurrentCompany } from '@/hooks/useCurrentCompany' +import { formatDate } from '@/lib/date' +import { useContext, useMemo, useState } from 'react' +import { formatCurrency } from '@/lib/numbers' +import { Label } from '@/components/ui/label' +import { FileDropzone } from '@/components/ui/file-dropzone' +import FileUploadBanner from '@/components/common/FileUploadBanner' +import { BankTransaction } from '@/types/Accounts/BankTransaction' +import { useHotkeys } from 'react-hotkeys-hook' +import { useDirection } from '@/components/ui/direction' +import BankLogo from '@/components/common/BankLogo' + +const TransferModal = () => { + + const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom) + + return ( + + + + {_("Transfer")} + + {_("Record an internal transfer to another bank/credit card/cash account.")} + + + + + + ) +} + +const TransferModalContent = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + + if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { + return
+ {_("No transaction selected")} +
+ } + + if (selectedTransaction.length === 1) { + return + } + + return + +} + +const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { + + const form = useForm<{ + bank_account: string + }>() + + const setIsOpen = useSetAtom(bankRecTransferModalAtom) + + const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer') + + const onReconcile = useRefreshUnreconciledTransactions() + const addToActionLog = useUpdateActionLog() + + const onSubmit = (data: { bank_account: string }) => { + + createPaymentEntry({ + bank_transaction_names: transactions.map((transaction) => transaction.name), + bank_account: data.bank_account + }).then(({ message }) => { + addToActionLog({ + type: 'transfer', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: item.payment_entry.name, + posting_date: item.payment_entry.posting_date, + doc: item.payment_entry, + } + })), + bulkCommonData: { + bank_account: data.bank_account, + } + }) + toast.success(_("Transfer Recorded"), { + duration: 4000, + closeButton: true, + }) + onReconcile(transactions[transactions.length - 1]) + setIsOpen(false) + }) + + } + + const onAccountChange = (account: string) => { + form.setValue('bank_account', account) + } + + const selectedAccount = useWatch({ control: form.control, name: 'bank_account' }) + + const currentCompany = useCurrentCompany() + + const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') + + console.log("This is here", transactions) + + return
+ +
+ + {error && } + + + + + + + + + + + +
+
+ + +} + +interface InternalTransferFormFields extends PaymentEntry { + mirror_transaction_name?: string +} + +const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => { + + + const setIsOpen = useSetAtom(bankRecTransferModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const form = useForm({ + defaultValues: { + payment_type: 'Internal Transfer', + company: selectedTransaction?.company, + // If the transaction is a withdrawal, set the paid from to the selected bank account + paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // If the transaction is a deposit, set the paid to to the selected bank account + paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // Set the amount to the amount of the selected transaction + paid_amount: selectedTransaction.unallocated_amount, + received_amount: selectedTransaction.unallocated_amount, + reference_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + + const [files, setFiles] = useState([]) + + const onSubmit = (data: InternalTransferFormFields) => { + + createPaymentEntry({ + bank_transaction_name: selectedTransaction.name, + ...data, + custom_remarks: data.remarks ? true : false, + // Pass this to reconcile both at the same time + mirror_transaction_name: data.mirror_transaction_name + }).then(async ({ message }) => { + addToActionLog({ + type: 'transfer', + timestamp: (new Date()).getTime(), + isBulk: false, + items: [ + { + bankTransaction: message.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: message.payment_entry.name, + reference_no: message.payment_entry.reference_no, + reference_date: message.payment_entry.reference_date, + posting_date: message.payment_entry.posting_date, + doc: message.payment_entry, + } + } + ] + }) + toast.success(_("Transfer Recorded"), { + duration: 4000, + closeButton: true, + action: { + label: _("Undo"), + onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) + }, + actionButtonStyle: { + backgroundColor: "rgb(0, 138, 46)" + } + }) + + if (files.length > 0) { + setIsUploading(true) + + const uploadPromises = files.map(f => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Payment Entry", + docname: message.payment_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + + setUploadProgress((currentProgress) => { + //If there are multiple files, we need to add the progress to the current progress + return currentProgress + ((progress?.progress ?? 0) / files.length) + }) + + }) + }) + + return Promise.all(uploadPromises).then(() => { + setUploadProgress(0) + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + }).then(() => { + setUploadProgress(0) + setIsUploading(false) + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + const onAccountChange = (account: string, is_mirror: boolean = false) => { + //If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into + if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) { + form.setValue('paid_to', account) + } else { + form.setValue('paid_from', account) + } + + if (!is_mirror) { + // Reset the mirror transaction name + form.setValue('mirror_transaction_name', '') + } + } + + const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' }) + + const direction = useDirection() + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ + +
+
+ + +
+ +
+
+ +
+

{isWithdrawal ? _('Transferred to') : _('Transferred from')}

+ + +
+
+
+
+ account.name !== selectedBankAccount.account} + isRequired + /> +
+ +
+ {direction === 'ltr' ? : } +
+
+ account.name !== selectedBankAccount.account} + /> +
+
+
+ +
+
+ + + +
+ + +
+
+
+ + + + + + +
+
+ +} + + +const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company: string }) => { + + const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount) + + return
+ {banks.map((bank) => ( +
onAccountChange(bank.account ?? '')} + > + +
+ {bank.account_name} {bank.bank_account_no && ({bank.bank_account_no})} + {bank.account} +
+
+ ))} + +
+ +} + +const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => { + + const { data } = useFrappeGetCall('frappe.client.get_value', { + doctype: 'Company', + filters: company, + fieldname: 'default_cash_account' + }, undefined, { + revalidateOnFocus: false, + revalidateIfStale: false, + }) + + const account = data?.message?.default_cash_account + + if (account) { + return
setSelectedAccount(account ?? '')} + > +
+ +
+
+ Cash + {data?.message?.default_cash_account} +
+
+ } + + return null +} + + +const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => { + + const { setValue, watch } = useFormContext() + + const mirrorTransactionName = watch('mirror_transaction_name') + const paid_from = watch('paid_from') + const paid_to = watch('paid_to') + + const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', { + transaction_id: transaction.name + }, undefined, { + revalidateOnFocus: false, + revalidateIfStale: false, + }) + + // Get bank accounts to find the logo + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + if (data?.message?.bank_account && banks) { + return banks.find(bank => bank.name === data.message.bank_account) + } + return null + }, [data?.message?.bank_account, banks]) + + const selectTransaction = () => { + if (data?.message) { + setValue('mirror_transaction_name', data.message.name) + onAccountChange(data.message.account, true) + } + } + + if (data?.message) { + + const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0 + + const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit + const currency = data.message.currency + + const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account + + const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected + + return (
+
+
+
+
+ + {_("Suggested Transfer to {0}", [data.message.account])} +
+
+ {_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])} + {_("Accepting the suggestion will reconcile both transactions.")} +
+ +
+
+ + {formatDate(data.message.date, 'Do MMM YYYY')} +
+ {data.message.description} +
+
+
+
+
+ +
+
+
+ {isWithdrawal ? : } + {isWithdrawal ? _('Transferred Out') : _('Received')} +
+
+ {formatCurrency(amount, currency)} +
+ +
+
+
+
+ ) + } + + return null +} + +export default TransferModal \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/bankRecAtoms.ts b/banking/src/components/features/BankReconciliation/bankRecAtoms.ts new file mode 100644 index 00000000000..4f6e51e51b1 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/bankRecAtoms.ts @@ -0,0 +1,83 @@ +import { BankAccount } from "@/types/Accounts/BankAccount"; +import { getDatesForTimePeriod } from "@/lib/date"; +import { atom } from "jotai"; +import { atomWithStorage, createJSONStorage } from "jotai/utils"; +import { atomFamily } from 'jotai-family' +import { UnreconciledTransaction } from "./utils"; +import { BankTransaction } from "@/types/Accounts/BankTransaction"; +import { PaymentEntry } from "@/types/Accounts/PaymentEntry"; +import { JournalEntry } from "@/types/Accounts/JournalEntry"; + +export interface SelectedBank extends Pick { + logo?: string, + logoDark?: string, + darkModeInvert?: boolean, + logoClassName?: string, + account_currency?: string +} +export const selectedBankAccountAtom = atom(null) + +export const bankRecDateAtom = atomWithStorage<{ fromDate: string, toDate: string }>("bank-rec-date", { + fromDate: getDatesForTimePeriod('This Month').fromDate, + toDate: getDatesForTimePeriod('This Month').toDate +}) + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const bankRecClosingBalanceAtom = atomFamily((_id: string) => { + return atom<{ value: number, stringValue: string | number | undefined }>({ + value: 0, + stringValue: '0.00' + }) +}) + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const bankRecSelectedTransactionAtom = atomFamily((_id: string) => { + return atom([]) +}) + +/** Action Modals */ +export const bankRecTransferModalAtom = atom(false) +export const bankRecRecordPaymentModalAtom = atom(false) +export const bankRecRecordJournalEntryModalAtom = atom(false) + +export const bankRecUnreconcileModalAtom = atom('') + +export const bankRecMatchFilters = atomWithStorage('bank-rec-match-filters', ['payment_entry', 'journal_entry']) + +export const bankRecSearchText = atom('') +export const bankRecAmountFilter = atom<{ value: number, stringValue?: string | number }>({ + value: 0, + stringValue: '0.00' +}) +export const bankRecTransactionTypeFilter = atom('All') + +export interface ActionLog { + type: 'match' | 'payment' | 'transfer' | 'bank_entry' + isBulk: boolean + timestamp: number, + items: ActionLogItem[], + bulkCommonData?: { + party_type?: string, + party?: string, + account?: string, + bank_account?: string, + } +} + +export interface ActionLogItem { + bankTransaction: BankTransaction, + voucher: { + reference_doctype: string, + reference_name: string, + reference_no?: string, + reference_date?: string, + posting_date: string, + doc?: PaymentEntry | JournalEntry + }, +} + +const actionLogStorage = createJSONStorage(() => sessionStorage) + +export const bankRecActionLog = atomWithStorage('bank-rec-action-log', [], actionLogStorage, { + getOnInit: true, +}) \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/logos.ts b/banking/src/components/features/BankReconciliation/logos.ts new file mode 100644 index 00000000000..8212b72c33f --- /dev/null +++ b/banking/src/components/features/BankReconciliation/logos.ts @@ -0,0 +1,397 @@ +export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[], logoDark?: string, darkModeInvert?: boolean, logoClassName?: string }[] = [ + // United States + International + { + keywords: ['American Express', 'Amex'], + logo: 'Amex.svg', + locale: ['Global', 'United States'] + }, + { + keywords: ['Bank of America', 'BOA'], + logo: 'Bank_of_America.png', + darkModeInvert: true, + locale: ['United States'] + }, + { + keywords: ['Barclays'], + logo: 'Barclays.svg', + locale: ['Global', 'United Kingdom'], + logoClassName: 'h-12', + }, + { + keywords: ['BNP Paribas'], + logo: 'BNP_Paribas.svg', + logoDark: 'BNP_Paribas-Dark.svg', + locale: ['Global', 'France'], + logoClassName: 'max-w-24' + }, + { + keywords: ['Bank of New York Mellon', 'BNY Mellon', 'BNY'], + logo: 'BNY_Mellon.svg', + locale: ['Global', 'United States'], + logoDark: 'BNY_Mellon-Dark.svg', + }, + { + keywords: ['Capital One'], + logo: 'Capital_One.png', + locale: ['United States'], + darkModeInvert: true + }, + { + keywords: ['Charles Schwab', 'Schwab'], + logo: 'Charles_Schwab.svg', + locale: ['United States'], + logoClassName: 'h-7' + }, + { + keywords: ['Chase'], + logo: 'chase.svg', + locale: ['Global', 'United States'], + logoDark: 'chase-Dark.svg', + }, + { + keywords: ['Citi', 'Citibank', 'Citi Group', 'Citi Financial Services'], + logo: 'Citi.svg', + locale: ['Global', 'United States'] + }, + { + keywords: ['Deutsche Bank'], + logo: 'Deutsche_Bank.svg', + locale: ['Global', 'Germany'], + darkModeInvert: true, + }, + { + keywords: ['Goldman Sachs'], + logo: 'Goldman_Sachs.svg', + locale: ['Global', 'United States'], + darkModeInvert: true, + }, + { + keywords: ['HSBC'], + logo: 'HSBC.svg', + locale: ['Global', 'United Kingdom'], + logoDark: 'HSBC-dark.svg', + }, + { + keywords: ['JPMorgan Chase', 'JPMorgan', 'JP Morgan', 'JP Morgan Chase', 'JPMorgan Chase & Co', 'JPM', 'JPMC'], + logo: 'jpmc.svg', + locale: ['Global', 'United States'], + darkModeInvert: true, + }, + { + keywords: ['Morgan Stanley'], + logo: 'Morgan_Stanley.png', + locale: ['Global', 'United States'], + darkModeInvert: true, + }, + { + keywords: ['PNC', 'PNC Financial Services Group', 'PNC Financial Services', 'Pittsburgh National Corporation'], + logo: 'PNC.png', + locale: ['United States'] + }, + { + keywords: ['Santander'], + logo: 'Santander.svg', + locale: ['Global'] + }, + { + keywords: ['TD Bank', 'Toronto Dominion Bank'], + logo: 'Toronto_Dominion_Bank.png', + locale: ['Canada'] + }, + { + keywords: ['Truist'], + logo: 'Truist.svg', + locale: ['United States'], + darkModeInvert: true, + logoClassName: 'h-8' + }, + { + keywords: ['UBS'], + logo: 'UBS.svg', + locale: ['Global', 'Switzerland'], + logoDark: 'UBS-dark.svg', + }, + { + keywords: ['US Bank', 'USBank', 'U.S. Bank', 'U.S. Bancorp'], + logo: 'USBank.svg', + locale: ['United States'], + logoDark: 'USBank-dark.svg', + }, + { + keywords: ['Wells Fargo', 'Wells Fargo'], + logo: 'Wells_Fargo.svg', + locale: ['United States'], + logoClassName: 'h-7' + }, + { + keywords: ['OakStar', 'Oakstar', 'Oakstar'], + logo: 'Oakstar.png', + logoDark: 'Oakstar-dark.webp', + locale: ['United States'], + logoClassName: 'h-7' + }, + { + keywords: ['PlainsCapital', 'Plains Capital'], + logo: 'PlainsCapitalBank.png', + locale: ['United States'], + logoClassName: 'h-7' + }, + { + keywords: ["Standard Chartered"], + logo: 'Standard_Chartered.png', + logoDark: 'Standard_Chartered-dark.png', + locale: ['Global'], + }, + // India + { + keywords: ['HDFC Bank', 'HDFC'], + logo: 'HDFC.svg', + locale: ['India'], + }, + { + keywords: ['ICICI Bank', 'ICICI'], + logo: 'ICICI.svg', + logoDark: 'ICICI-dark.svg', + locale: ['India'], + }, + { + keywords: ['SBI', 'State Bank of India'], + logo: 'State_Bank_of_India.svg', + logoDark: 'State_bank_of_India-Dark.svg', + locale: ['India'], + logoClassName: 'h-4.5' + }, + { + keywords: ['Punjab National Bank', 'PNB'], + logo: 'Punjab_National_Bank.svg', + locale: ['India'] + }, + { + keywords: ['Union Bank of India', 'Union Bank'], + logo: 'Union_Bank_of_India.svg', + locale: ['India'] + }, + { + keywords: ['Yes Bank', 'Yes'], + logo: 'Yes_Bank.svg', + locale: ['India'], + logoDark: 'Yes_Bank-dark.svg', + }, + { + keywords: ['RBL Bank', 'RBL'], + logo: 'RBL_Bank.svg', + locale: ['India'], + logoDark: 'RBL_Bank-dark.svg', + }, + { + keywords: ['Axis Bank', 'Axis'], + logo: 'Axis_Bank.svg', + locale: ['India'], + darkModeInvert: true + }, + { + keywords: ['Bank of Baroda', 'BOB'], + logo: 'Bank_of_Baroda.svg', + locale: ['India', 'Kenya'], + logoClassName: 'h-7' + }, + { + keywords: ['Bank of India', 'BOI'], + logo: 'Bank_of_India.png', + locale: ['India'], + logoClassName: 'h-7' + }, + { + keywords: ['Bank of Maharashtra', 'BOM'], + logo: 'Bank_of_Maharashtra.png', + locale: ['India'], + logoClassName: 'min-w-24' + }, + { + keywords: ['Kotak Mahindra Bank', 'Kotak'], + logo: 'Kotak_Mahindra.svg', + locale: ['India'] + }, + { + keywords: ['IndusInd Bank', 'IndusInd'], + logo: 'IndusInd_Bank.svg', + locale: ['India'], + darkModeInvert: true, + }, + { + keywords: ['IDBI Bank', 'IDBI'], + logo: 'IDBI_Bank.svg', + locale: ['India'] + }, + { + keywords: ['IDFC First Bank', 'IDFC First'], + logo: 'IDFC_First_Bank.svg', + locale: ['India'] + }, + { + keywords: ['Federal Bank'], + logo: 'Federal_Bank.png', + logoDark: 'Federal_Bank-dark.png', + locale: ['India'] + }, + { + keywords: ['Fi Bank'], + logo: 'Fi_Bank.svg', + locale: ['India'] + }, + { + keywords: ['RazorpayX', 'Razorpay'], + logo: 'Razorpay.svg', + logoDark: 'Razorpay-dark.svg', + locale: ['India'] + }, + { + keywords: ['Revolut'], + logo: 'Revolut.png', + locale: ['Global'], + darkModeInvert: true + }, + { + keywords: ['Starling Bank'], + logo: 'Starling_Bank.png', + logoDark: 'Starling_Bank-dark.png', + locale: ['Global', 'UK'], + logoClassName: 'h-10' + }, + // Australia and New Zealand + { + keywords: ["Commonwealth Bank", "CBA"], + logo: "Commonwealth_Bank.svg", + locale: ['Australia', 'New Zealand'], + }, + { + keywords: ["Airwallex"], + logo: "Airwallex.png", + logoDark: "Airwallex-dark.png", + locale: ['Global'] + }, + { + keywords: ["Judo Bank"], + logo: "Judo_Bank.svg", + logoDark: "Judo_Bank-dark.svg", + locale: ['Australia', 'New Zealand'] + }, + { + keywords: ["Alpha"], // This might conflict with Alpha Bank in Greece + logo: "Alpha_Bank.svg", + darkModeInvert: true, + logoClassName: 'h-4.5', + locale: ['Australia', 'New Zealand'] + }, + { + keywords: ["Australian Tax Office", "Australian Taxation Office"], + logo: "Australian_Tax_Office.png", + darkModeInvert: true, + locale: ['Australia'] + }, + { + keywords: ["Westpac"], + logo: "Westpac.svg", + locale: ['Australia'] + }, + { + keywords: ["ANZ", "ANZ Bank", "Australia and New Zealand Banking Group"], + logo: "ANZ.png", + locale: ['Australia', 'New Zealand'] + }, + { + keywords: ["Macquarie Group", "Macquarie Bank"], + logo: "Macquarie.svg", + darkModeInvert: true, + locale: ['Australia'] + }, + // Nicaragua + { + keywords: ["Banco Atlantida", "Banco Atlántida"], + logo: "Banco_Atlantida.png", + locale: ['Nicaragua'] + }, + { + keywords: ["Banco de Finanzas"], + logo: "Banco_de_Finanzas.svg", + locale: ['Nicaragua'], + logoClassName: 'h-4.5' + }, + { + keywords: ["Avanz"], + logo: "Avanz.svg", + logoDark: "Avanz-dark.svg", + locale: ['Nicaragua'], + logoClassName: 'h-7' + }, + { + keywords: ["Ficohsa"], + logo: "Ficohsa.svg", + locale: ['Nicaragua'] + }, + { + keywords: ["BAC", "BAC Credomatic"], + logo: "BAC_Credomatic.svg", + locale: ['Nicaragua'], + logoClassName: 'h-4.5' + }, + { + keywords: ["Banco Lafise"], + logo: "Banco_Lafise.png", + darkModeInvert: true, + locale: ['Nicaragua'] + }, + // German + { + keywords: ["Sparkasse"], + logo: "Sparkasse.png", + locale: ['Germany'] + }, + { + keywords: ["Volksbank", "Raiffeisenbank", "VR-Bank"], + logo: "Volksbanken_Raiffeisenbanken.svg", + locale: ['Germany'], + logoClassName: 'min-w-32' + }, + // Kenya + { + keywords: ["KCB Bank", "KCB"], + logo: "KCB_Bank_Kenya.png", + locale: ['Kenya'] + }, + { + keywords: ["Equity Bank"], + logo: "Equity_Bank.png", + logoDark: "Equity_Bank-dark.png", + locale: ['Kenya'], + }, + { + keywords: ["I&M"], + logo: "I&M.png", + locale: ['Kenya'] + }, + { + keywords: ["ABSA"], + logo: "ABSA.png", + locale: ['Kenya'], + darkModeInvert: true, + logoClassName: 'h-7' + }, + { + keywords: ["Stanbic"], + logo: "Stanbic.png", + locale: ['Kenya'], + logoClassName: 'h-7' + }, + { + keywords: ["DTB", "Diamond Trust Bank"], + logo: "Diamond_Trust_Bank.png", + locale: ['Kenya'] + }, + { + keywords: ["Prime Bank"], + logo: "Prime_Bank.png", + locale: ['Kenya'], + logoClassName: 'max-w-28' + } +] \ No newline at end of file diff --git a/banking/src/components/features/BankReconciliation/utils.ts b/banking/src/components/features/BankReconciliation/utils.ts new file mode 100644 index 00000000000..833fb8afd4c --- /dev/null +++ b/banking/src/components/features/BankReconciliation/utils.ts @@ -0,0 +1,457 @@ +import { ActionLog, bankRecActionLog, bankRecAmountFilter, bankRecDateAtom, bankRecMatchFilters, bankRecSearchText, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useMemo } from 'react' +import { SWRConfiguration, useFrappeGetCall, useFrappeGetDoc, useFrappePostCall, useSWRConfig } from 'frappe-react-sdk' +import { BankTransaction } from '@/types/Accounts/BankTransaction' +import { BankAccount } from '@/types/Accounts/BankAccount' +import dayjs from 'dayjs' +import { toast } from 'sonner' +import { BANK_LOGOS } from './logos' +import { getErrorMessage } from '@/lib/frappe' +import { useCurrentCompany } from '@/hooks/useCurrentCompany' +import _ from '@/lib/translate' +import { BankTransactionRule } from '@/types/Accounts/BankTransactionRule' +import { useRef } from 'react' +import type { DebouncedState } from 'usehooks-ts' +import { useDebounceCallback } from 'usehooks-ts' +import Fuse from 'fuse.js' + +export const useGetAccountOpeningBalance = () => { + + const companyID = useCurrentCompany() + const bankAccount = useAtomValue(selectedBankAccountAtom) + + const dates = useAtomValue(bankRecDateAtom) + + const args = useMemo(() => { + + return { + bank_account: bankAccount?.name, + company: companyID, + till_date: dayjs(dates.fromDate).subtract(1, 'days').format('YYYY-MM-DD'), + } + + }, [companyID, bankAccount?.name, dates.fromDate]) + + return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args, undefined, { + revalidateOnFocus: false + }) +} + +export const useGetAccountClosingBalance = () => { + + const companyID = useCurrentCompany() + const bankAccount = useAtomValue(selectedBankAccountAtom) + + const dates = useAtomValue(bankRecDateAtom) + + const args = useMemo(() => { + + return { + bank_account: bankAccount?.name, + company: companyID, + till_date: dates.toDate, + } + + }, [companyID, bankAccount?.name, dates.toDate]) + + return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args, + `bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`, + { + revalidateOnFocus: false + } + ) + +} + +/** + * Hook to fetch the closing balance set in the database for the given bank and date + */ +export const useGetAccountClosingBalanceAsPerStatement = (swrConfig: SWRConfiguration = {}) => { + + const dates = useAtomValue(bankRecDateAtom) + const bankAccount = useAtomValue(selectedBankAccountAtom) + + return useFrappeGetCall<{ message: { balance: number, date?: string } }>("erpnext.accounts.doctype.bank_account.bank_account.get_closing_balance_as_per_statement", { + bank_account: bankAccount?.name, + date: dates.toDate + }, `bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${dates.toDate}`, { + revalidateOnFocus: false, + ...swrConfig + }) +} + +export type UnreconciledTransaction = Pick + + +export const useGetUnreconciledTransactions = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + return useFrappeGetCall<{ message: UnreconciledTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', { + bank_account: bankAccount?.name, + from_date: dates.fromDate, + to_date: dates.toDate + }, bankAccount ? `bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null, { + revalidateOnFocus: false, + revalidateIfStale: false + }) +} + +export interface LinkedPayment { + rank: number, + doctype: string, + name: string, + paid_amount: number, + reference_no: string, + reference_date: string, + posting_date: string, + party_type?: string, + party?: string, + currency: string +} + +export const useGetBankTransactions = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + return useFrappeGetCall<{ message: BankTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', { + bank_account: bankAccount?.name, + from_date: dates.fromDate, + to_date: dates.toDate, + all_transactions: true + }, bankAccount ? `bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null) +} + + +export const useGetVouchersForTransaction = (transaction: UnreconciledTransaction) => { + + const dates = useAtomValue(bankRecDateAtom) + + const matchFilters = useAtomValue(bankRecMatchFilters) + + return useFrappeGetCall<{ message: LinkedPayment[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_linked_payments', { + bank_transaction_name: transaction.name, + document_types: matchFilters ?? ['payment_entry', 'journal_entry'], + from_date: dates.fromDate, + to_date: dates.toDate, + filter_by_reference_date: 0 + }, `bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`, { + revalidateOnFocus: false + }) +} + +/** + * Common hook to refresh the unreconciled transactions list after a transaction is reconciled + * @returns function to call to refresh the unreconciled transactions list AFTER the operation is done + */ +export const useRefreshUnreconciledTransactions = () => { + + const selectedBank = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + const matchFilters = useAtomValue(bankRecMatchFilters) + const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(selectedBank?.name || '')) + + const { mutate } = useSWRConfig() + + const searchString = useAtomValue(bankRecSearchText) + const typeFilter = useAtomValue(bankRecTransactionTypeFilter) + const amountFilter = useAtomValue(bankRecAmountFilter) + + const { data: unreconciledTransactions } = useGetUnreconciledTransactions() + + /** + * This function should be called after a transaction is reconciled + * It will get the next unreconciled transaction and select it + * And then refresh the balance + unreconciled transactions list + */ + const onReconcileTransaction = (transaction: UnreconciledTransaction, updatedTransaction?: BankTransaction) => { + + // If the updated transaction has an unallocated amount of 0, then we need to select the next unreconciled transaction + if (updatedTransaction && updatedTransaction?.unallocated_amount !== 0) { + mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`) + // Update the matching vouchers for the selected transaction + mutate(`bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`) + return + } + + // From unreconciled transactions list, first apply the filters based on the search criteria and other filters + + const searchIndex = unreconciledTransactions ? new Fuse(unreconciledTransactions.message, { + keys: ['description', 'reference_number'], + threshold: 0.5, + includeScore: true + }) : null + + const results = getSearchResults(searchIndex, searchString, typeFilter, amountFilter.value, unreconciledTransactions?.message) + + const currentIndex = results.findIndex(t => t.name === transaction.name) + let nextTransaction = null + + if (currentIndex !== -1) { + // Check if there is a next transaction + if (currentIndex < (results.length || 0) - 1) { + nextTransaction = results[currentIndex + 1] + } + } + + // We need to select the next unreconciled transaction for a better UX + mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`) + .then(res => { + if (nextTransaction) { + // Check if next transaction is there in the response + const nextTransactionObj = res?.message.find((t: UnreconciledTransaction) => t.name === nextTransaction.name) + if (nextTransactionObj) { + setSelectedTransaction([nextTransactionObj]) + } else { + // If the next transaction is not there in the response, we need to clear the selection + setSelectedTransaction([]) + } + } else { + // If there is no next transaction, we need to clear the selection + setSelectedTransaction([]) + } + }) + mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`) + } + + return onReconcileTransaction + +} + +export const useReconcileTransaction = () => { + + const { call, loading } = useFrappePostCall<{ message: BankTransaction }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers') + + const onReconcileTransaction = useRefreshUnreconciledTransactions() + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + + const addToActionLog = useUpdateActionLog() + + const reconcileTransaction = (transaction: UnreconciledTransaction, voucher: LinkedPayment) => { + + call({ + bank_transaction_name: transaction.name, + vouchers: JSON.stringify([{ + "payment_doctype": voucher.doctype, + "payment_name": voucher.name, + "amount": voucher.paid_amount + }]) + }).then((res) => { + addToActionLog({ + type: 'match', + timestamp: (new Date()).getTime(), + isBulk: false, + items: [ + { + bankTransaction: res.message, + voucher: { + reference_doctype: voucher.doctype, + reference_name: voucher.name, + reference_no: voucher.reference_no, + reference_date: voucher.reference_date, + posting_date: voucher.posting_date, + } + } + ] + }) + onReconcileTransaction(transaction, res.message) + toast.success(_("Reconciled"), { + duration: 4000, + closeButton: true, + action: { + label: _("Undo"), + onClick: () => setBankRecUnreconcileModalAtom(transaction.name) + }, + actionButtonStyle: { + backgroundColor: "rgb(0, 138, 46)" + } + }) + }).catch((error) => { + console.error(error) + toast.error(_("Error"), { + duration: 5000, + description: getErrorMessage(error) + }) + }) + } + + return { reconcileTransaction, loading } + +} + +interface BankAccountWithCurrency extends Pick { + account_currency?: string +} + +type BankLogoEntry = (typeof BANK_LOGOS)[number] + +/** Prefer the longest keyword match so short tokens (e.g. "anz" in "finanzas") do not beat full bank names. */ +function findBankLogoForName(bankName: string | undefined | null): BankLogoEntry | undefined { + if (!bankName) return undefined + const haystack = bankName.toLowerCase() + let best: BankLogoEntry | undefined + let bestKeywordLen = 0 + for (const entry of BANK_LOGOS) { + for (const keyword of entry.keywords) { + const needle = keyword.toLowerCase() + if (needle.length === 0) continue + if (haystack.includes(needle) && needle.length > bestKeywordLen) { + bestKeywordLen = needle.length + best = entry + } + } + } + return best +} + +export const useGetBankAccounts = (onSuccess?: (data?: Omit[]) => void, filterFn?: (bank: SelectedBank) => boolean) => { + + const company = useCurrentCompany() + + const { data, isLoading, error } = useFrappeGetCall<{ message: BankAccountWithCurrency[] }>('erpnext.accounts.doctype.bank_account.bank_account.get_list', { + company: company + }, undefined, { + revalidateOnFocus: false, + revalidateIfStale: false, + onSuccess: (data) => { + onSuccess?.(data?.message) + } + }) + + const banks = useMemo(() => { + // Match the bank account to the logo + const banksWithLogos = data?.message.map((bank) => { + const logo = findBankLogoForName(bank.bank) + return { + ...bank, + logo: logo?.logo, + logoDark: logo?.logoDark, + darkModeInvert: logo?.darkModeInvert, + logoClassName: logo?.logoClassName + } + }) ?? [] + + if (filterFn) { + return banksWithLogos.filter(filterFn) + } + + return banksWithLogos + }, [data, filterFn]) + + return { + banks, + isLoading, + error + } + +} + +export const useIsTransactionWithdrawal = (transaction: UnreconciledTransaction) => { + return useMemo(() => { + const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0 + const isDeposit = transaction.deposit && transaction.deposit > 0 + + return { + amount: isWithdrawal ? transaction.withdrawal : transaction.deposit, + isWithdrawal, + isDeposit + } + }, [transaction]) +} + +export const useGetRuleForTransaction = (transaction: UnreconciledTransaction) => { + + return useFrappeGetDoc('Bank Transaction Rule', transaction.matched_transaction_rule, + transaction.matched_transaction_rule ? undefined : null, { + revalidateOnFocus: false, + revalidateIfStale: false + } + ) +} + +/** Hook to handle the search input while maintaining debouncing and global state. */ +export function useTransactionSearch(): [string, DebouncedState<(value: string) => void>] { + const delay = 500 + const unwrappedInitialValue = '' + const eq = (left: string, right: string) => left === right + const [debouncedValue, setDebouncedValue] = useAtom(bankRecSearchText) + const previousValueRef = useRef(unwrappedInitialValue) + + const updateDebouncedValue = useDebounceCallback( + setDebouncedValue, + delay, + ) + + // Update the debounced value if the initial value changes + if (!eq(previousValueRef.current as string, unwrappedInitialValue)) { + updateDebouncedValue(unwrappedInitialValue) + previousValueRef.current = unwrappedInitialValue + } + + return [debouncedValue, updateDebouncedValue] +} + +/** Utility function to get the search results based on the search index, search string, type filter, amount filter and unreconciled transactions */ +export const getSearchResults = ( + /** Fuse index of the unreconciled transactions */ + searchIndex: Fuse | null, + /** Search string */ + search: string, + /** Type filter */ + typeFilter: string, + /** Amount filter */ + amountFilter: number, + /** Unreconciled transactions */ + unreconciledTransactions?: UnreconciledTransaction[]) => { + + let r = [] + if (!searchIndex || !search) { + r = unreconciledTransactions ?? [] + } else { + r = searchIndex.search(search).map((result) => result.item) + } + + if (typeFilter !== 'All') { + r = r.filter((transaction) => { + if (typeFilter === 'Debits') { + return transaction.withdrawal && transaction.withdrawal > 0 + } + if (typeFilter === 'Credits') { + return transaction.deposit && transaction.deposit > 0 + } + }) + } + + if (amountFilter > 0) { + r = r.filter((transaction) => { + if (transaction.withdrawal && transaction.withdrawal > 0) { + return transaction.withdrawal === amountFilter + } + if (transaction.deposit && transaction.deposit > 0) { + return transaction.deposit === amountFilter + } + return false + }) + } + + return r +} + +export const useUpdateActionLog = () => { + + const setActionLog = useSetAtom(bankRecActionLog) + + const addToActionLog = (action: ActionLog) => { + // Store at max 100 actions + setActionLog((prev) => { + const newActions = [action, ...prev] + if (newActions.length > 100) { + return newActions.slice(0, 100) + } + return newActions + }) + } + + return addToActionLog +} \ No newline at end of file diff --git a/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx b/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx new file mode 100644 index 00000000000..23edf987404 --- /dev/null +++ b/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx @@ -0,0 +1,22 @@ +import CSVRawDataPreview from './CSVRawDataPreview' +import StatementDetails from './StatementDetails' +import _ from '@/lib/translate' +import { GetStatementDetailsResponse } from '../import_utils' + +const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => { + + + + return ( +
+
+ +
+
+ +
+
+ ) +} + +export default CSVImport \ No newline at end of file diff --git a/banking/src/components/features/BankStatementImporter/CSV/CSVRawDataPreview.tsx b/banking/src/components/features/BankStatementImporter/CSV/CSVRawDataPreview.tsx new file mode 100644 index 00000000000..31a00a90694 --- /dev/null +++ b/banking/src/components/features/BankStatementImporter/CSV/CSVRawDataPreview.tsx @@ -0,0 +1,151 @@ +import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import _ from "@/lib/translate" +import { GetStatementDetailsResponse } from "../import_utils" +import { useMemo } from "react" +import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap" + + +const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => { + + const column_mapping: Record = useMemo(() => { + + const col_map: Record = {} + + data.doc.column_mapping?.forEach(col => { + if (col.maps_to && col.maps_to !== "Do not import") { + col_map[col.maps_to] = col.index; + } + }) + + return col_map + + }, [data]) + + const validColumns = Object.values(column_mapping) + + // Reverse the column mapping to get a map of column index to variable name + const columnIndexMap: Record = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes])) + + // Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows + return ( + + + {data.raw_data.map((row, index) => { + + const isHeaderRow = index === data.doc.detected_header_index; + const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0); + + return + {isHeaderRow ? + {index + 1} + : + + {index + 1} + + } + {row.map((cell, cellIndex) => { + + const isValidColumn = validColumns.includes(cellIndex); + const columnType = columnIndexMap[cellIndex]; + const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType); + + if (isHeaderRow) { + return +
+ {columnType && + + + + + {_(columnType)} + + + } + {cell} +
+
+ } else { + return +
+ {cell} +
+
+ } + } + + )} +
+ })} +
+
+ ) +} + +type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to']; + +const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => { + if (!columnType) { + return null + } + + if (columnType === 'Amount') { + return + } + + if (columnType === 'Withdrawal') { + return + } + + if (columnType === 'Deposit') { + return + } + + if (columnType === 'Balance') { + return + } + + if (columnType === 'Date') { + return + } + + if (columnType === 'Description') { + return + } + + if (columnType === 'Reference') { + return + } + + if (columnType === 'Transaction Type') { + return + } + + if (columnType === 'Debit/Credit') { + return + } + + return null +} + +export default CSVRawDataPreview \ No newline at end of file diff --git a/banking/src/components/features/BankStatementImporter/CSV/StatementDetails.tsx b/banking/src/components/features/BankStatementImporter/CSV/StatementDetails.tsx new file mode 100644 index 00000000000..74f40eb7e33 --- /dev/null +++ b/banking/src/components/features/BankStatementImporter/CSV/StatementDetails.tsx @@ -0,0 +1,351 @@ +import _ from '@/lib/translate' +import { GetStatementDetailsResponse } from '../import_utils' +import { flt, formatCurrency } from '@/lib/numbers' +import { formatDate } from '@/lib/date' +import { bankRecDateAtom } from '../../BankReconciliation/bankRecAtoms' +import { AlertCircleIcon, ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, InfoIcon, Loader2Icon } from 'lucide-react' +import { H2, H3, Paragraph } from '@/components/ui/typography' +import { FileTypeIcon } from '@/components/ui/file-dropzone' +import { getFileExtension } from '@/lib/file' +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Separator } from '@/components/ui/separator' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk' +import { toast } from 'sonner' +import ErrorBanner from '@/components/ui/error-banner' +import { Link, useNavigate } from 'react-router-dom' +import { useMemo, useState } from 'react' +import { Progress } from '@/components/ui/progress' +import { useSetAtom } from 'jotai' +import { useDirection } from '@/components/ui/direction' +import BankLogo from '@/components/common/BankLogo' +import { useGetBankAccounts } from '../../BankReconciliation/utils' +import { BankStatementImportLog } from '@/types/Accounts/BankStatementImportLog' +import { Badge } from '@/components/ui/badge' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' + +const parseDateFormat = (dateFormat: string) => { + + const charMap = { + "%d": "DD", + "%m": "MM", + "%Y": "YYYY", + "%y": "YY", + "%b": "MMM", + "%B": "MMMM", + } + + let label = dateFormat + + Object.keys(charMap).forEach((char) => { + label = label.replace(char, charMap[char as keyof typeof charMap]) + }) + + return dateFormat + +} + +type Props = { + data: GetStatementDetailsResponse, +} + +const StatementDetails = ({ data }: Props) => { + const dateFormat = parseDateFormat(data.date_format) + + const { call, loading, error } = useFrappePostCall<{ docs: BankStatementImportLog[] }>('run_doc_method') + + const navigate = useNavigate() + + const setDates = useSetAtom(bankRecDateAtom) + + const direction = useDirection() + + const onImport = () => { + + call({ + docs: data.doc, + method: 'insert_transactions' + }).then((response) => { + const doc = response.docs ? response.docs[0] : undefined + if (doc && doc.start_date && doc.end_date) { + setDates({ + fromDate: doc.start_date, + toDate: doc.end_date, + }) + } + toast.success(_("Bank statement imported.")) + navigate(`/`) + }).catch(() => { + toast.error(_("There was an error while importing the bank statement.")) + }) + + } + + const [progress, setProgress] = useState(0) + + useFrappeEventListener("bank-rec-statement-import-progress", (event) => { + setProgress(event.progress) + }) + + const file_name = data.doc.file.split("/").pop() ?? "" + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + + return banks?.find((bank) => bank.name === data.doc.bank_account) + + }, [data.doc.bank_account, banks]) + + return ( +
+
+
+ + {data.doc.status === 'Completed' ? {_("Completed")} : + + } +
+
+
+

{_("Statement Details")}

+ + {_("We've auto-detected the details of the statement file.")} +
+ + {_("Please review the details below and click the 'Import' button to proceed.")} + +
+
+
+ + {progress > 0 &&
+ {_("Importing {0} transactions", [progress.toString()])} + +
} + + {error && } + + + + + {_("Bank Account")} + +
+ + {bank?.account_name} + {bank?.account} +
+
+
+ + {_("Statement File")} + +
+ + {file_name} +
+
+
+ + {_("Transaction Dates")} + {_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])} + + + {_("Number of Transactions")} + {data.doc.number_of_transactions} + + + {_("Total Debits")} + {formatCurrency(flt(data.doc.total_debits, 2), data.currency)} ({data.doc.total_debit_transactions} {data.doc.total_debit_transactions === 1 ? _("transaction") : _("transactions")}) + + + {_("Total Credits")} + {formatCurrency(flt(data.doc.total_credits, 2), data.currency)} ({data.doc.total_credit_transactions} {data.doc.total_credit_transactions === 1 ? _("transaction") : _("transactions")}) + + + {_("Closing Balance as of {}", [formatDate(data.doc.end_date, "Do MMMM YYYY")])} + {formatCurrency(flt(data.doc.closing_balance, 2), data.currency)} + + + +
+ {_("Detected Amount Format")} + + + {_("The amount format detected in the statement file. This is used to parse the deposit and withdrawal values from each row.")} + + +
+
+ {data.doc.detected_amount_format} +
+ + +
+ {_("Detected Date Format")} + + + + {_("The date format detected in the statement file. This is used to parse the date values.")} + + +
+
+ + {dateFormat || data.date_format} (e.g.{" "} + {formatDate(new Date(), dateFormat || "YYYY-MM-DD")}) + +
+
+
+
+ + {data.doc.status === "Not Started" ? <> + + + + + +
+
+

{_("Preview Transactions")}

+ {data.final_transactions?.length === 1 ? ( + {_("We've found 1 transaction in the statement file that will be imported into the system. Please review the details below and click the 'Import' button to proceed.")} + ) : ( + {_("{0} transactions will be imported into the system. Please review the details below and click the 'Import' button to proceed.", [data.final_transactions?.length?.toString() || "0"])} + )} +
+
+ + {_("Transactions to be imported into the system")} + + + # + {_("Date")} + {_("Description")} + {_("Ref.")} + {_("Withdrawal")} + {_("Deposit")} + + + + {data.final_transactions?.map((transaction, index) => ( + + {index + 1} + {formatDate(transaction.date)} + {transaction.description} + {transaction.reference} + {formatCurrency(transaction.withdrawal, data.currency)} + {formatCurrency(transaction.deposit, data.currency)} + + ))} + +
+
+
+ : null} +
+ + ) +} + +const ConflictingTransactions = ({ transactions }: { transactions: GetStatementDetailsResponse["conflicting_transactions"] }) => { + + if (transactions.length === 0) { + return null + } + + return <> + + + {_("Conflicting Transactions")} + + {transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?") + : _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])} + +
+ + + + + + + {_("Conflicting Transactions")} + + {transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?") + : _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])} + + + +
+ + {_("Existing transactions in the system belonging to the same bank account and date range")} + + + {_("Date")} + {_("Description")} + {_("Ref.")} + {_("Withdrawal")} + {_("Deposit")} + + + + + {transactions.map((transaction) => ( + + {formatDate(transaction.date)} + {transaction.description} + {transaction.reference_number ? transaction.reference_number : "-"} + {formatCurrency(transaction.withdrawal, transaction.currency)} + {formatCurrency(transaction.deposit, transaction.currency)} + + + + + + + {_("Open {0} in a new tab", [transaction.name])} + + + + + + ))} + +
+
+ + + + + +
+ +
+
+
+
+ +} + +export default StatementDetails \ No newline at end of file diff --git a/banking/src/components/features/BankStatementImporter/import_utils.ts b/banking/src/components/features/BankStatementImporter/import_utils.ts new file mode 100644 index 00000000000..1f918977751 --- /dev/null +++ b/banking/src/components/features/BankStatementImporter/import_utils.ts @@ -0,0 +1,42 @@ +import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog" +import { useFrappeGetCall } from "frappe-react-sdk" + + +export interface GetStatementDetailsResponse { + doc: BankStatementImportLog, + conflicting_transactions: Array<{ + name: string, + date: string, + withdrawal: number, + deposit: number, + description: string, + reference_number: string, + currency: string, + }>, + final_transactions: Array<{ + date: string, + withdrawal: number, + deposit: number, + description: string, + reference: string, + transaction_type?: string, + debit_credit?: string, + included_fee?: number, + excluded_fee?: number, + party_name?: string, + party_account_number?: string, + party_iban?: string, + }>, + date_format: string, + raw_data: Array>, + currency: string, +} + +export const useGetStatementDetails = (id: string) => { + return useFrappeGetCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.get_statement_details", { + statement_import_id: id, + }, undefined, { + revalidateOnFocus: false + }) + +} \ No newline at end of file diff --git a/banking/src/components/features/Settings/KeyboardShortcuts.tsx b/banking/src/components/features/Settings/KeyboardShortcuts.tsx new file mode 100644 index 00000000000..435a0ac2ab0 --- /dev/null +++ b/banking/src/components/features/Settings/KeyboardShortcuts.tsx @@ -0,0 +1,115 @@ +import { Badge } from '@/components/ui/badge' +import { Kbd, KbdGroup } from '@/components/ui/kbd' +import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys' +import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import _ from '@/lib/translate' +import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react' + +const Shortcuts = [ + { + shortcut: B, + action: { + icon: , + label: _("Bank Entry"), + description: _("Record a bank journal entry for expenses, income or split transactions") + } + }, + { + shortcut: P, + action: { + icon: , + label: _("Record Payment"), + description: _("Record a payment against a customer or supplier") + } + }, + { + shortcut: I, + action: { + icon: , + label: _("Transfer"), + description: _("Record a transfer between two bank accounts") + } + }, + { + shortcut: R, + action: { + icon: , + label: _("Accept Matching Rule"), + description: _("Accept the rule for the selected transaction") + } + }, + { + shortcut: S, + action: { + icon: , + label: _("Save"), + description: _("Save the currently opened form") + } + }, + { + shortcut: Z, + action: { + icon: , + label: _("Reconciliation History"), + description: _("View all reconciliation actions taken in this session") + } + }, + { + shortcut: ⇧G, + action: { + icon: , + label: _("Settings"), + description: _("Open the settings dialog") + } + } +] + +const KeyboardShortcuts = () => { + return ( + <> + + {_("Keyboard Shortcuts")} + {_("Get around the system quickly with keyboard shortcuts")} + + +
+

+ {_("Transaction actions work when one or more unreconciled transactions are selected.")} +
+ {_("To select more than one transaction at a time, press and hold the shift key.")} +

+ + + + {_("Shortcut")} + {_("Action")} + {_("Description")} + + + + {Shortcuts.map((shortcut) => ( + + + {shortcut.shortcut} + + + + {shortcut.action.icon} + {shortcut.action.label} + + + +

{shortcut.action.description}

+
+
+ ))} +
+
+
+
+ + ) +} + +export default KeyboardShortcuts \ No newline at end of file diff --git a/banking/src/components/features/Settings/MatchingRules.tsx b/banking/src/components/features/Settings/MatchingRules.tsx new file mode 100644 index 00000000000..5e42856b199 --- /dev/null +++ b/banking/src/components/features/Settings/MatchingRules.tsx @@ -0,0 +1,46 @@ +import { Button } from '@/components/ui/button' +import { SettingsPanelTitle, SettingsPanelHeader, SettingsPanelDescription, SettingsPanelContent } from '@/components/ui/settings-dialog' +import _ from '@/lib/translate' +import { PlusIcon } from 'lucide-react' +import { useState } from 'react' +import RuleList, { RunRulesButton } from './Rules/RuleList' +import CreateNewRule from '../BankReconciliation/Rules/CreateNewRule' +import EditRule from '../BankReconciliation/Rules/EditRule' + +const MatchingRules = () => { + + const [selectedRule, setSelectedRule] = useState(null) + const [isNewRule, setIsNewRule] = useState(false) + + + if (isNewRule) { + return setIsNewRule(false)} /> + } + + if (selectedRule) { + return setSelectedRule(null)} ruleID={selectedRule} /> + } + + return ( + <> + + + + + } + > + {_("Transaction Matching Rules")} + + + {_("Set up rules to automatically classify transactions. Drag and drop rules to reorder their priority.")} + + + + + + + ) +} +export default MatchingRules diff --git a/banking/src/components/features/Settings/Preferences.tsx b/banking/src/components/features/Settings/Preferences.tsx new file mode 100644 index 00000000000..b182485a7ec --- /dev/null +++ b/banking/src/components/features/Settings/Preferences.tsx @@ -0,0 +1,261 @@ +import ErrorBanner from "@/components/ui/error-banner" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle, SettingsPanelContent } from "@/components/ui/settings-dialog" +import { Switch } from "@/components/ui/switch" +import { useTheme } from "@/components/ui/theme-provider" +import _ from "@/lib/translate" +import { AccountsSettings } from "@/types/Accounts/AccountsSettings" +import { useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk" +import { toast } from "sonner" + + +export const Preferences = () => { + + + const { data: accountsSettings, mutate, error: fetchError, isLoading } = useFrappeGetDoc("Accounts Settings", "Accounts Settings", undefined, { + revalidateOnFocus: false + }) + + const { updateDoc, error } = useFrappeUpdateDoc() + + const onUpdate = (field: keyof AccountsSettings, value: any) => { + mutate(updateDoc("Accounts Settings", "Accounts Settings", { + [field]: value + }), { + optimisticData: { + ...accountsSettings as AccountsSettings, + [field]: value + }, + revalidate: false, + }).then(() => { + toast.success(_("Preferences updated"), { + dismissible: true, + duration: 500, + }) + }) + } + + return <> + + + {_("Preferences")} + {_("Configure settings for the banking module")} + + + +
+ {fetchError && } + {error && } + +
+ + + +
+
+ +

+ {_("For example, if set to 4, the system will try to find matching transfer transactions in other banks 4 days before and after the transaction date. This is because transactions can clear on different days on different bank accounts.")} +

+
+
+ +
+
+ + + +
+
+ +

+ {_("This will automatically run transaction matching rules on unreconciled transactions every hour.")} +

+
+
+ onUpdate("automatically_run_rules_on_unreconciled_transactions", checked ? 1 : 0)} + /> +
+
+ + + +
+
+ +

+ {_("The system will attempt to automatically match a party to a bank transaction based on account number or IBAN.")} + +

+
+
+ onUpdate("enable_party_matching", checked ? 1 : 0)} + /> +
+
+ + + +
+
+ +

+ {_("If a party cannot be matched by account number or IBAN, the system will try fuzzy matching using the party name and transaction description.")} + +

+
+
+ onUpdate("enable_fuzzy_matching", checked ? 1 : 0)} + /> +
+
+ +
+ + + + {/* */} + +
+
+ +} + + +const ThemeSwitcher = () => { + + const { theme, setTheme } = useTheme() + + const themeCards: Array<{ value: "Light" | "Dark" | "Automatic", label: string }> = [ + { + value: "Light", + label: _("Light"), + }, + { + value: "Dark", + label: _("Dark"), + }, + { + value: "Automatic", + label: _("System"), + }, + ] + + return
+
+ +

+ {_("Switch between light, dark, or system theme")} +

+
+
+ {themeCards.map((option) => { + const selected = theme === option.value + + return ( + + ) + })} +
+
+ +} + +const ThemePreviewWindow = ({ theme, roundedClass }: { theme: "light" | "dark", roundedClass: string }) => { + const isLight = theme === "light" + const frameClass = isLight ? "bg-white border-gray-100" : "bg-gray-900 border-gray-800" + const subtleSurfaceClass = isLight ? "bg-gray-50" : "bg-gray-800" + const mutedLineClass = isLight ? "bg-gray-200" : "bg-gray-700" + const mutedLineStrongClass = isLight ? "bg-gray-300" : "bg-gray-600" + const dividerClass = isLight ? "border-gray-100" : "border-gray-800" + const cardClass = isLight ? "bg-white border-gray-200" : "bg-gray-900 border-gray-700" + + return
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
*/} +
+
+
+
+
+
+
+
+} \ No newline at end of file diff --git a/banking/src/components/features/Settings/Rules/RuleList.tsx b/banking/src/components/features/Settings/Rules/RuleList.tsx new file mode 100644 index 00000000000..a05b8735235 --- /dev/null +++ b/banking/src/components/features/Settings/Rules/RuleList.tsx @@ -0,0 +1,314 @@ +import { Button } from "@/components/ui/button" +import ErrorBanner from "@/components/ui/error-banner" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import _ from "@/lib/translate" +import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule" +import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappeGetDocList, useFrappePostCall } from "frappe-react-sdk" +import { ArrowDownRight, ArrowDownUp, ArrowUpRight, MoreVertical, Trash2, GripVertical, Play, RefreshCw, ZapIcon, CalendarSyncIcon } from "lucide-react" +import { useContext, useState } from "react" +import { toast } from "sonner" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { cn } from "@/lib/utils" + +const useGetRuleList = () => { + return useFrappeGetDocList("Bank Transaction Rule", { + fields: ["name", "rule_name", "rule_description", "transaction_type", "priority"], + orderBy: { + field: 'priority', + order: 'asc' + }, + limit: 100 + }) +} + +export const RunRulesButton = () => { + + const { data } = useGetRuleList() + + const { call: runRuleEvaluation, loading: isRunningRules } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction_rule.bank_transaction_rule.run_rule_evaluation') + + const handleRunRules = async (forceEvaluate: boolean = false) => { + try { + await runRuleEvaluation({ + force_evaluate: forceEvaluate + }) + toast.success(forceEvaluate ? _("Rules evaluation started") : _("Rules evaluation completed")) + } catch (error) { + toast.error(_("Failed to run rules evaluation")) + console.error("Error running rules evaluation:", error) + } + } + + if (!data || data.length === 0) { + return null + } + + return + + + + + handleRunRules(false)} disabled={isRunningRules} title={_("Run rules on unreconciled transactions that haven't been evaluated yet")}> + + {_("Run on new transactions")} + + handleRunRules(true)} disabled={isRunningRules} title={_("Force re-evaluate all unreconciled transactions, even if they were previously evaluated")}> + + {_("Force evaluate all")} + + + + + +} + +const AutoRunRuleItem = () => { + + const { db } = useContext(FrappeContext) as FrappeConfig + + const { data: accountsSetting, mutate: setAutomaticallyRunRulesOnUnreconciledTransactions } = useFrappeGetCall("frappe.client.get_single_value", { + "doctype": "Accounts Settings", + "field": "automatically_run_rules_on_unreconciled_transactions" + }) + + const automaticallyRunRulesOnUnreconciledTransactions = accountsSetting?.message ? true : false + + const onAutoClassifyTransactions = (checked: boolean) => { + toast.promise(db.setValue("Accounts Settings", "Accounts Settings", "automatically_run_rules_on_unreconciled_transactions", checked ? 1 : 0).then(() => { + setAutomaticallyRunRulesOnUnreconciledTransactions({ + message: { + automatically_run_rules_on_unreconciled_transactions: checked ? 1 : 0, + } + }, { + revalidate: false + }) + }), { + loading: _("Updating..."), + success: checked ? _("Scheduled job enabled. Transactions will be auto classified.") : _("Scheduled job disabled. Transactions will not be auto classified."), + error: _("Failed to update auto classify transactions settings") + }) + } + + + return + + {_("Run rules automatically")} + +} + + + +const RuleList = ({ setSelectedRule }: { setSelectedRule: (rule: string) => void }) => { + + const { data, error, isLoading, mutate } = useGetRuleList() + + const { db } = useContext(FrappeContext) as FrappeConfig + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const onDeleteRule = (ruleID: string) => { + toast.promise(db.deleteDoc("Bank Transaction Rule", ruleID).then(() => { + mutate() + }), { + loading: _("Deleting rule..."), + success: _("Rule deleted."), + error: _("Failed to delete rule.") + }) + } + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event + + if (active.id !== over?.id && data) { + const oldIndex = data.findIndex((rule) => rule.name === active.id) + const newIndex = data.findIndex((rule) => rule.name === over?.id) + + const newData = arrayMove(data, oldIndex, newIndex) + + // Update priorities based on new order + const updatePromises = newData.map((rule, index) => { + const newPriority = index + 1 + if (rule.priority !== newPriority) { + return db.setValue("Bank Transaction Rule", rule.name, "priority", newPriority) + } + return Promise.resolve() + }) + + try { + await Promise.all(updatePromises) + toast.success(_("Rule priorities updated")) + mutate() // Refresh the data + } catch (error) { + toast.error(_("Failed to update rule priorities")) + console.error("Error updating priorities:", error) + } + } + } + + return ( + <> +
+ {isLoading &&
+ + + + + +
} + + {error && } + + {data && data.length === 0 && + + + + + {_("No rules setup yet")} + {_("Configure rules to save time when reconciling transactions.")} + + + } + + {data && data.length > 0 && ( + + rule.name)} + strategy={verticalListSortingStrategy} + > +
    + {data?.map((rule) => ( + + ))} +
+
+
+ )} +
+ + ) +} +const SortableRuleItem = ({ + rule, + setSelectedRule, + onDeleteRule +}: { + rule: BankTransactionRule + setSelectedRule: (rule: string) => void + onDeleteRule: (ruleID: string) => void +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: rule.name }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + + return ( +
  • +
    +
    +
    + +
    + + {rule.priority} + +
    +
    + +
    + {rule.transaction_type === "Any" ? : rule.transaction_type === "Withdrawal" ? : } +
    +
    + + {rule.rule_description} + +
    +
    + +
    + + + + + + onDeleteRule(rule.name)}> + + {_("Delete")} + + + +
    +
    +
  • + ) +} + +export default RuleList diff --git a/banking/src/components/features/Settings/Settings.tsx b/banking/src/components/features/Settings/Settings.tsx new file mode 100644 index 00000000000..623fceea4ac --- /dev/null +++ b/banking/src/components/features/Settings/Settings.tsx @@ -0,0 +1,95 @@ +import { Button } from '@/components/ui/button' +import { Dialog, DialogTrigger } from '@/components/ui/dialog' +import { + SettingsDialog, + SettingsPanel, + SettingsPanels, + SettingsTabGroup, + SettingsTabItem, + SettingsTabs, +} from '@/components/ui/settings-dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import _ from '@/lib/translate' +import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react' +import { useState } from 'react' +import { Preferences } from './Preferences' +import MatchingRules from './MatchingRules' +import KeyboardShortcuts from './KeyboardShortcuts' +import { useHotkeys } from 'react-hotkeys-hook' + +const Settings = () => { + + const [isOpen, setIsOpen] = useState(false) + + useHotkeys('shift+meta+g', () => { + setIsOpen(x => !x) + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: false + }) + + return ( + + + + + + + + + {_("Settings")} + + + setIsOpen(false)}> + + + } + label={_("Preferences")} + value="preferences" + /> + } + label={_("Matching Rules")} + value="rules" + /> + {/* } + label={_("Bank Accounts")} + value="bank-accounts" + /> + } + label={_("Masters")} + value="masters" + /> */} + } + label={_("Keyboard Shortcuts")} + value="keyboard-shortcuts" + /> + + + + + + + + + + + + + + + + + + + ) +} + +export default Settings diff --git a/banking/src/components/ui/alert-dialog.tsx b/banking/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000000..cbab3099bb3 --- /dev/null +++ b/banking/src/components/ui/alert-dialog.tsx @@ -0,0 +1,196 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertDialogAction({ + className, + variant = "solid", + size = "md", + theme = "red", + ...props +}: React.ComponentProps & + Pick, "variant" | "size" | "theme">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "md", + theme = "gray", + ...props +}: React.ComponentProps & + Pick, "variant" | "size" | "theme">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/banking/src/components/ui/alert.tsx b/banking/src/components/ui/alert.tsx new file mode 100644 index 00000000000..556f064a2b3 --- /dev/null +++ b/banking/src/components/ui/alert.tsx @@ -0,0 +1,104 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3.5 text-base grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-1 [&>svg]:text-current", + { + variants: { + variant: { + subtle: "bg-surface-white", + outline: "border border-outline-gray-3", + }, + theme: { + gray: "text-ink-gray-8", + blue: "text-ink-blue-3", + green: "text-ink-green-3", + red: "text-ink-red-3", + amber: "text-ink-amber-3", + } + }, + compoundVariants: [ + // Subtle alerts + { + theme: "gray", + variant: "subtle", + className: "bg-surface-gray-2 border-outline-gray-1" + }, + { + theme: "blue", + variant: "subtle", + className: "bg-surface-blue-2 border-surface-blue-2" + }, + { + theme: "green", + variant: "subtle", + className: "bg-surface-green-2 border-surface-green-2" + }, + { + theme: "red", + variant: "subtle", + className: "bg-surface-red-2 border-surface-red-2" + }, + { + theme: "amber", + variant: "subtle", + className: "bg-surface-amber-2 border-surface-amber-2" + } + ], + defaultVariants: { + variant: "subtle", + theme: "gray", + }, + } +) + +export type AlertProps = React.ComponentProps<"div"> & VariantProps + +function Alert({ + className, + variant, + theme, + ...props +}: AlertProps) { + return ( +
    + ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/banking/src/components/ui/badge.tsx b/banking/src/components/ui/badge.tsx new file mode 100644 index 00000000000..099588f0b02 --- /dev/null +++ b/banking/src/components/ui/badge.tsx @@ -0,0 +1,188 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center select-none rounded-full whitespace-nowrap gap-1 w-fit shrink-0 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + solid: "", + subtle: "", + outline: "bg-transparent border", + ghost: "bg-transparent", + }, + size: { + sm: 'h-4 text-xs px-1.5 [&>svg]:size-2.5', + md: 'h-5 text-xs px-1.5 [&>svg]:size-3', + lg: 'h-6 text-sm px-2 [&>svg]:size-3', + }, + theme: { + gray: "", + blue: "", + green: "", + red: "", + orange: "", + violet: "", + } + }, + compoundVariants: [ + // Solid badges + { + variant: "solid", + theme: "gray", + className: "text-ink-white bg-surface-gray-7 [a&]:hover:bg-surface-gray-8" + }, + { + variant: "solid", + theme: "blue", + className: "text-ink-blue-1 bg-surface-blue-5 [a&]:hover:bg-surface-blue-6" + }, + { + variant: "solid", + theme: "green", + className: "text-ink-green-1 bg-surface-green-5 [a&]:hover:bg-surface-green-6" + }, + { + variant: "solid", + theme: "orange", + className: "text-ink-amber-1 bg-surface-amber-5 [a&]:hover:bg-surface-amber-6" + }, + { + variant: "solid", + theme: "red", + className: "text-ink-red-1 bg-surface-red-5 [a&]:hover:bg-surface-red-6" + }, + { + variant: "solid", + theme: "violet", + className: "text-ink-violet-1 bg-surface-violet-5 [a&]:hover:bg-surface-violet-6" + }, + // Subtle badge + { + variant: "subtle", + theme: "gray", + className: "text-ink-gray-6 bg-surface-gray-2 [a&]:hover:bg-surface-gray-3" + }, + { + variant: "subtle", + theme: "blue", + className: "text-ink-blue-4 bg-surface-blue-2 [a&]:hover:bg-surface-blue-3" + }, + { + variant: "subtle", + theme: "green", + className: "text-ink-green-4 bg-surface-green-2 [a&]:hover:bg-surface-green-3" + }, + { + variant: "subtle", + theme: "orange", + className: "text-ink-amber-4 bg-surface-amber-2 [a&]:hover:bg-surface-amber-3" + }, + { + variant: "subtle", + theme: "red", + className: "text-ink-red-4 bg-surface-red-2 [a&]:hover:bg-surface-red-3" + }, + { + variant: "subtle", + theme: "violet", + className: "text-ink-violet-4 bg-surface-violet-2 [a&]:hover:bg-surface-violet-3" + }, + // Outline badge + { + variant: "outline", + theme: "gray", + className: "text-ink-gray-6 border-outline-gray-2 [a&]:hover:bg-surface-gray-2" + }, + { + variant: "outline", + theme: "blue", + className: "text-ink-blue-4 border-outline-blue-2 [a&]:hover:bg-surface-blue-2" + }, + { + variant: "outline", + theme: "green", + className: "text-ink-green-4 border-outline-green-2 [a&]:hover:bg-surface-green-2" + }, + { + variant: "outline", + theme: "orange", + className: "text-ink-amber-4 border-outline-amber-2 [a&]:hover:bg-surface-amber-2" + }, + { + variant: "outline", + theme: "red", + className: "text-ink-red-4 border-outline-red-2 [a&]:hover:bg-surface-red-2" + }, + { + variant: "outline", + theme: "violet", + className: "text-ink-violet-4 border-outline-violet-2 [a&]:hover:bg-surface-violet-2" + }, + // Ghost badge + { + variant: "ghost", + theme: "gray", + className: "text-ink-gray-6" + }, + { + variant: "ghost", + theme: "blue", + className: "text-ink-blue-4" + }, + { + variant: "ghost", + theme: "green", + className: "text-ink-green-4" + }, + { + variant: "ghost", + theme: "orange", + className: "text-ink-amber-4" + }, + { + variant: "ghost", + theme: "red", + className: "text-ink-red-4" + }, + { + variant: "ghost", + theme: "violet", + className: "text-ink-violet-4" + } + ], + defaultVariants: { + variant: "subtle", + size: "md", + theme: "gray", + }, + } +) + +function Badge({ + className, + variant = "subtle", + size = "md", + theme = "gray", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/banking/src/components/ui/breadcrumb.tsx b/banking/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000000..73dfac94c6f --- /dev/null +++ b/banking/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { MoreHorizontal } from "lucide-react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return