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 ?
+
+ {value || _('Select Account')}
+
+
+
+
+ :
+ {value || _('Select Account')}
+
+
+ }
+
+
+
+
+
+ {_("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 ?? ''}
+ >
+ }
+ )
+}
+
+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 ?
+
+ {linkTitle || placeholder}
+
+
+
+
+ :
+ {linkTitle || placeholder}
+
+ }
+
+
+ {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 (
+
+ {useInForm ?
+
+
+
+ :
+
+
+ }
+
+ {filteredData.map((option) => (
+ {option.name}
+ ))}
+
+
+ )
+}
+
+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.")}
+
+
+
+
+ setIsOpen(false)}>{_("Close")}
+
+
+
+
+ )
+}
+
+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)}
+
+
+
+
+
+
+
+
+
+
+
+}
+
+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")}
+
+
+ {loading ? : _(("Undo"))}
+
+
+
+
+}
+
+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
+
+}
+
+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 ?? ''))}
+
+ onDelete(item.name)}>
+
+
+
+
+ ))}
+
+
+
+
+}
+
+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 (
+
+
+ onCopy(ref)}
+ >
+ {ref}
+
+
+
+ {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
+
+
+
+ {_("Force Clear")}
+
+
+ {_("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
+
+}
+
+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
+
+}
+
+
+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
+
+
+}
+
+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 &&
+ {_("Remove")}
+
}
+
+
+
+
+
+}
+
+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")}
+
+
+
+ {formatCurrency(total, currency)}
+
+
+
+ {_("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.")}
+
+
+
+
+ {_("Configure Bank Accounts")}
+
+
+
+
+ }
+ 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.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {timePeriodOptions.map((period) => (
+ handleTimePeriodChange(period.fromDate, period.toDate)}>
+
+ {period.translatedLabel ?? _(period.label)}
+
+
+ {formatDate(period.fromDate, period.format)} {direction === 'ltr' ? : } {formatDate(period.toDate, period.format)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {formatDate(bankRecDate.fromDate)} - {formatDate(bankRecDate.toDate)}
+
+
+
+ {
+ 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 (
+ onCopy(ref)}
+ >
+ {ref}
+
+ )
+ },
+ },
+ {
+ 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 }) => (
+
+
+
+ {_("View")}
+
+
+ {row.original.allocated_amount && row.original.allocated_amount > 0 ? (
+
onUndo(row.original)}
+ size="sm"
+ theme='red'
+ >
+
+ {_("Undo")}
+
+ ) : 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} `])
+ }} />
+
+
+
+
+
+ {_("Import Bank Statement")}
+
+
+
+
+ {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
+
+ {_("Search transactions")}
+
+
+
+
+
+ {results?.length} {_(results?.length === 1 ? "result" : "results")}
+
+
+
+
+ {_("Filter by amount")}
+ {
+ // 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}
+ />
+
+
+
+
+
+
+ {typeFilter === 'All' ?
: typeFilter === 'Debits' ?
:
}
+ {_(typeFilter)}
+
+
+
+
+
+ onTypeFilterChange('All')}> {_("All")}
+ onTypeFilterChange('Debits')}> {_("Debits")}
+ onTypeFilterChange('Credits')}> {_("Credits")}
+
+
+
+
+
+
+
+
+ {status === 'All' ? :
+ status === 'Reconciled' ? :
+ status === 'Unreconciled' ? :
+ }
+ {_(status)}
+
+
+
+
+
+
+ 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 {_(voucher.payment_document)}: {voucher.payment_entry}
+ })}
+ }
+
+
+
+ {_("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 (
+
+
+
+
+ {selectedCompany}
+
+
+
+
+
+
+ {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 }) => (
+ onClearClick(row.original.payment_document, row.original.payment_entry)}
+ >
+ {_("Reset Clearing Date")}
+
+ ),
+ },
+ ],
+ [_, 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
+
+
+
+ {_("Search transactions")}
+
+
+
+
+
+ {results?.length} {_(results?.length === 1 ? "result" : "results")}
+
+
+
+ {_("Filter by amount")}
+ {
+ // 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}
+ />
+
+
+
+
+
+ {typeFilter === 'All' ? : typeFilter === 'Debits' ? : }
+ {_(typeFilter)}
+
+
+
+
+ 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 ? Clear Filters :
+
+
+ {_("Import Bank Statement")}
+
+ }
+
+
+}
+
+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 ?? '')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setRecordJournalEntryModalOpen(true)}>
+ {_("Bank Entry")}
+
+
+
+ {_("Record a journal entry for expenses, income or split transactions")}
+
+
+ B
+
+
+
+
+
+ setRecordPaymentModalOpen(true)}>
+ {_("Record Payment")}
+
+
+
+ {_("Record a payment entry against a customer or supplier")}
+
+
+ P
+
+
+
+
+
+
+ setTransferModalOpen(true)}>
+ {_("Transfer")}
+
+
+
+ {_("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
+
+
+
+
+
+ setRecordPaymentModalOpen(true)}>
+ {_("Record Payment")}
+
+
+
+ {_("Record a payment entry against a customer or supplier")}
+
+
+ P
+
+
+
+
+
+ setRecordJournalEntryModalOpen(true)}>
+ {_("Bank Entry")}
+
+
+
+ {_("Record a journal entry for expenses, income or split transactions")}
+
+
+ B
+
+
+
+
+
+ setTransferModalOpen(true)}>
+ {_("Transfer")}
+
+
+
+ {_("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)})
+
+ )}
+
+
+
+
+ {getActionIcon()}
+ {_("Create")} {rule.classify_as}
+
+
+ {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.party && voucher.party_type &&
}
+
+
+
+
{_("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")}`}
+
+
+
+
}
+
+
+
+ {loading ? <> {_("Reconciling")}...> : `${_("Reconcile")}`}
+
+
+
+ {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?")}
+
+
+
+
setDates({ fromDate: data.message.oldest_date, toDate: dates.toDate })}>
+ {data.message.count > 1 ? _("View older transactions") : _("View older transaction")}
+
+
+
+
+
+ }
+
+ 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))
+ }
+ }} />
+ {label}
+
+}
+
+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
+}
\ 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
+
+
+}
+
+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
+
+}
+
+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 &&
+ {_("Remove")}
+
}
+
+
+
+
+
+}
+
+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")}
+
+
+ onAddRow(unallocatedAmount ?? 0)}>
+ {formatCurrency(unallocatedAmount, currency)}
+
+
+
+ {_("Add a charge to the payment entry with the unallocated amount")}
+
+
+
+
+
: null}
+
+ {(differenceAmount && differenceAmount !== 0) ?
+ {_("Difference")}
+
+
+ onAddRow(differenceAmount ?? 0)}>
+ {formatCurrency(differenceAmount, currency)}
+
+
+
+ {_("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 &&
+ Get Unpaid Invoices
+ }
+
+
+ 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))}
+
+
+
+ Cancel
+
+ Select
+
+
+
+
+}
+
+
+
+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 &&
+ {_("Remove")}
+
}
+
+
+
+}
+
+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 (
+ <>
+
+ onCreate()}>{_("Cancel")}
+
+ {_("Save")}
+
+
+ }
+ >
+
+ {_("New Rule")}
+
+
+ {_("Create a new rule to automatically classify transactions.")}
+
+
+
+
+
+
+ >
+
+ )
+}
+
+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 <>
+
+ onClose()}>{_("Cancel")}
+
+ {_("Save")}
+
+
+ }
+ >
+
+ {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 (
+
+
+ )
+}
+
+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")}
+
+
+
+
+
+
+ remove(index)} disabled={fields.length === 1}>
+
+
+
+
+ ))}
+
+
+
+
+ {_("Add Rule")}
+
+
+
+
+ )
+}
+
+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
+
+ {_("Journal Template Accounts")}*
+ setIsConfigureAccountsModalOpen(true)}> {_("Configure Accounts")}
+
+
+
+
+
+
+ {_("Account")}
+ {_("Debit")}
+ {_("Credit")}
+
+
+
+ {accounts.length === 0 && (
+
+
+
+ {_("No accounts configured")}
+ setIsConfigureAccountsModalOpen(true)}>{_("Configure Accounts")}
+
+
+
+ )}
+ {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 &&
+ {_("Remove")}
+
}
+
+
+
+
+
+
+
+
{_("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
+
+
+}
+
+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
+
+}
+
+
+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)}
+
+
+ {isSuggested ? : }
+ {isSuggested ? _("Accepted") : _("Use Suggestion")}
+
+
+
+
+
+ )
+ }
+
+ 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 (
+
+
+
+
+
+ {direction === 'ltr' ? : }
+ {_("Back")}
+
+
+ {data.doc.status === 'Completed' ? {_("Completed")} :
+
+ {loading ? : null}
+ {loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}
+ }
+
+
+
+
{_("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()])}
+
+
+
+
+
+ {transactions.length > 1 ? _("View transactions") : _("View transaction")}
+
+
+
+
+ {_("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])}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {_("Close")}
+
+
+
+
+
+
+
+
+ >
+}
+
+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 (
+ <>
+
+
+ setIsNewRule(true)}> {_("Add Rule")}
+
+ }
+ >
+ {_("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 &&
}
+
+
+
+
+
+
+
+
{_("Number of days to match transfers")}
+
+ {_("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.")}
+
+
+
+ onUpdate("transfer_match_days", Number(value))} value={accountsSettings?.transfer_match_days?.toString()}>
+
+
+
+
+ {_("Same day")}
+ {_("Within 1 day")}
+ {_("Within 2 days")}
+ {_("Within 3 days")}
+ {_("Within 4 days")}
+ {_("Within 5 days")}
+
+
+
+
+
+
+
+
+
+
{_("Automatically run rules on unreconciled transactions")}
+
+ {_("This will automatically run transaction matching rules on unreconciled transactions every hour.")}
+
+
+
+ onUpdate("automatically_run_rules_on_unreconciled_transactions", checked ? 1 : 0)}
+ />
+
+
+
+
+
+
+
+
{_("Enable automatic party matching")}
+
+ {_("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)}
+ />
+
+
+
+
+
+
+
+
{_("Enable party name/description fuzzy matching")}
+
+ {_("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
+
+
{_("Theme")}
+
+ {_("Switch between light, dark, or system theme")}
+
+
+
+ {themeCards.map((option) => {
+ const selected = theme === option.value
+
+ return (
+
setTheme(option.value)}
+ aria-pressed={selected}
+ className={`flex-1 basis-0 min-w-0 overflow-hidden rounded-lg border cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-blue-4 ${selected ? "border-outline-gray-5" : "border-outline-gray-modals hover:border-outline-gray-4"}`}
+ >
+ {option.value === "Automatic" ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+ })}
+
+
+
+}
+
+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
+
+
+ {isRunningRules ? (
+
+ ) : (
+
+ )}
+ {isRunningRules ? _("Running...") : _("Run Rules")}
+
+
+
+ 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}
+
+
+
+
setSelectedRule(rule.name)}>
+ {rule.rule_name}
+
+
+ {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
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "a"
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? / }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/banking/src/components/ui/button.tsx b/banking/src/components/ui/button.tsx
new file mode 100644
index 00000000000..ebfebac3cad
--- /dev/null
+++ b/banking/src/components/ui/button.tsx
@@ -0,0 +1,263 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap transition-all disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
+ {
+ variants: {
+ variant: {
+ solid: "text-ink-white",
+ subtle: "",
+ ghost: "bg-transparent",
+ outline: "bg-surface-white border",
+ link: "bg-transparent underline-offset-4 underline",
+ },
+ size: {
+ sm: "h-7 text-base px-2 rounded [&_svg:not([class*='size-'])]:size-4",
+ md: "h-8 text-base font-medium px-2.5 rounded [&_svg:not([class*='size-'])]:size-4.5",
+ lg: "h-10 text-lg font-medium px-3 rounded-md [&_svg:not([class*='size-'])]:size-5",
+ xl: "h-11.5 text-xl font-medium px-3.5 rounded-lg [&_svg:not([class*='size-'])]:size-6",
+ "2xl": "h-13 text-2xl font-medium px-3.5 rounded-xl [&_svg:not([class*='size-'])]:size-6",
+ },
+ theme: {
+ gray: "focus-visible:shadow-focus-gray",
+ blue: "focus-visible:shadow-focus-blue",
+ green: "focus-visible:shadow-focus-green",
+ red: "focus-visible:shadow-focus-red",
+ amber: "focus-visible:shadow-focus-amber",
+ violet: "focus-visible:shadow-focus-violet",
+ },
+ isIconButton: {
+ true: "px-0",
+ false: ""
+ }
+ },
+ compoundVariants: [
+ // Icon only buttons - Sizes
+ {
+ isIconButton: true,
+ size: "sm",
+ className: "size-7"
+ },
+ {
+ isIconButton: true,
+ size: "md",
+ className: "size-8"
+ },
+ {
+ isIconButton: true,
+ size: "lg",
+ className: "size-10"
+ },
+ {
+ isIconButton: true,
+ size: "xl",
+ className: "size-11.5"
+ },
+ {
+ isIconButton: true,
+ size: "2xl",
+ className: "size-13"
+ },
+ // Solid buttons
+ {
+ variant: "solid",
+ theme: "gray",
+ className: "bg-surface-gray-7 hover:bg-surface-gray-6 active:bg-surface-gray-5 disabled:bg-surface-gray-2 disabled:text-ink-gray-4"
+ },
+ {
+ variant: "solid",
+ theme: "blue",
+ className: "bg-surface-blue-5 text-ink-blue-1 hover:bg-surface-blue-6 active:bg-surface-blue-7 disabled:bg-surface-blue-2 disabled:text-ink-blue-2"
+ },
+ {
+ variant: "solid",
+ theme: "green",
+ className: "bg-surface-green-5 text-ink-green-1 hover:bg-surface-green-6 active:bg-surface-green-7 disabled:bg-surface-green-2 disabled:text-ink-green-2"
+ },
+ {
+ variant: "solid",
+ theme: "red",
+ className: "bg-surface-red-5 text-ink-red-1 hover:bg-surface-red-6 active:bg-surface-red-7 disabled:bg-surface-red-2 disabled:text-ink-red-2"
+ },
+ {
+ variant: "solid",
+ theme: "violet",
+ className: "bg-surface-violet-5 text-ink-violet-1 hover:bg-surface-violet-6 active:bg-surface-violet-7 disabled:bg-surface-violet-2 disabled:text-ink-violet-2"
+ },
+ {
+ variant: "solid",
+ theme: "amber",
+ className: "bg-surface-amber-5 text-ink-amber-1 hover:bg-surface-amber-6 active:bg-surface-amber-7 disabled:bg-surface-amber-2 disabled:text-ink-amber-2"
+ },
+ // Subtle Buttons
+ {
+ variant: "subtle",
+ theme: "gray",
+ className: "text-ink-gray-7 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 disabled:bg-surface-gray-2 disabled:text-ink-gray-4"
+ },
+ {
+ variant: "subtle",
+ theme: "blue",
+ className: "text-ink-blue-4 bg-surface-blue-2 hover:bg-surface-blue-3 active:bg-surface-blue-4 disabled:bg-surface-blue-2 disabled:text-ink-blue-2"
+ },
+ {
+ variant: "subtle",
+ theme: "green",
+ className: "text-ink-green-4 bg-surface-green-2 hover:bg-surface-green-3 active:bg-surface-green-4 disabled:bg-surface-green-2 disabled:text-ink-green-2"
+ },
+ {
+ variant: "subtle",
+ theme: "red",
+ className: "text-ink-red-4 bg-surface-red-2 hover:bg-surface-red-3 active:bg-surface-red-4 disabled:bg-surface-red-2 disabled:text-ink-red-2"
+ },
+ {
+ variant: "subtle",
+ theme: "violet",
+ className: "text-ink-violet-4 bg-surface-violet-2 hover:bg-surface-violet-3 active:bg-surface-violet-4 disabled:bg-surface-violet-2 disabled:text-ink-violet-2"
+ },
+ {
+ variant: "subtle",
+ theme: "amber",
+ className: "text-ink-amber-4 bg-surface-amber-2 hover:bg-surface-amber-3 active:bg-surface-amber-4 disabled:bg-surface-amber-2 disabled:text-ink-amber-2"
+ },
+ // Outline buttons
+ {
+ variant: "outline",
+ theme: "gray",
+ className:
+ "text-ink-gray-7 border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 active:bg-surface-gray-4 disabled:bg-surface-gray-2 disabled:text-ink-gray-4 disabled:border-outline-gray-2"
+ },
+ {
+ variant: "outline",
+ theme: "blue",
+ className:
+ "text-ink-blue-4 border-outline-blue-2 hover:border-outline-blue-3 active:border-outline-blue-4 active:bg-surface-blue-4 disabled:bg-surface-blue-2 disabled:text-ink-blue-2 disabled:border-outline-blue-2"
+ },
+ {
+ variant: "outline",
+ theme: "green",
+ className:
+ "text-ink-green-4 border-outline-green-2 hover:border-outline-green-3 active:border-outline-green-4 active:bg-surface-green-4 disabled:bg-surface-green-2 disabled:text-ink-green-2 disabled:border-outline-green-2"
+ },
+ {
+ variant: "outline",
+ theme: "red",
+ className:
+ "text-ink-red-4 border-outline-red-2 hover:border-outline-red-3 active:border-outline-red-4 active:bg-surface-red-4 disabled:bg-surface-red-2 disabled:text-ink-red-2 disabled:border-outline-red-2"
+ },
+ {
+ variant: "outline",
+ theme: "violet",
+ className: "text-ink-violet-4 border-outline-violet-2 hover:border-outline-violet-3 active:border-outline-violet-4 active:bg-surface-violet-4 disabled:bg-surface-violet-2 disabled:text-ink-violet-2 disabled:border-outline-violet-2"
+ },
+ {
+ variant: "outline",
+ theme: "amber",
+ className: "text-ink-amber-4 border-outline-amber-2 hover:border-outline-amber-3 active:border-outline-amber-4 active:bg-surface-amber-4 disabled:bg-surface-amber-2 disabled:text-ink-amber-2 disabled:border-outline-amber-2"
+ },
+ // Ghost buttons
+ {
+ variant: "ghost",
+ theme: "gray",
+ className:
+ "text-ink-gray-7 hover:bg-surface-gray-3 active:bg-surface-gray-4 disabled:text-ink-gray-4"
+ },
+ {
+ variant: "ghost",
+ theme: "blue",
+ className:
+ "text-ink-blue-4 hover:bg-surface-blue-3 active:bg-surface-blue-4 disabled:text-ink-blue-2"
+ },
+ {
+ variant: "ghost",
+ theme: "green",
+ className:
+ "text-ink-green-4 hover:bg-surface-green-3 active:bg-surface-green-4 disabled:text-ink-green-2"
+ },
+ {
+ variant: "ghost",
+ theme: "red",
+ className:
+ "text-ink-red-4 hover:bg-surface-red-3 active:bg-surface-red-4 disabled:text-ink-red-2"
+ },
+ {
+ variant: "ghost",
+ theme: "violet",
+ className: "text-ink-violet-4 hover:bg-surface-violet-3 active:bg-surface-violet-4 disabled:text-ink-violet-2"
+ },
+ {
+ variant: "ghost",
+ theme: "amber",
+ className: "text-ink-amber-4 hover:bg-surface-amber-3 active:bg-surface-amber-4 disabled:text-ink-amber-2"
+ },
+ //Link buttons
+ {
+ variant: "link",
+ theme: "gray",
+ className: "text-ink-gray-8 hover:text-ink-gray-8 active:text-ink-gray-8 disabled:text-ink-gray-4"
+ },
+ {
+ variant: "link",
+ theme: "blue",
+ className: "text-ink-blue-3 hover:text-ink-blue-4 active:text-ink-blue-4 disabled:text-ink-blue-link"
+ },
+ {
+ variant: "link",
+ theme: "green",
+ className: "text-ink-green-3 hover:text-ink-green-4 active:text-ink-green-4 disabled:text-ink-green-2"
+ },
+ {
+ variant: "link",
+ theme: "red",
+ className: "text-ink-red-3 hover:text-ink-red-4 active:text-red-4 disabled:text-ink-red-2"
+ },
+ {
+ variant: "link",
+ theme: "violet",
+ className: "text-ink-violet-3 hover:text-ink-violet-4 active:text-ink-violet-4 disabled:text-ink-violet-2"
+ },
+ {
+ variant: "link",
+ theme: "amber",
+ className: "text-ink-amber-3 hover:text-ink-amber-4 active:text-ink-amber-4 disabled:text-ink-amber-2"
+ }
+ ],
+ defaultVariants: {
+ variant: "solid",
+ size: "sm",
+ theme: "gray",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "solid",
+ size = "sm",
+ theme = "gray",
+ isIconButton = false,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/banking/src/components/ui/calendar.tsx b/banking/src/components/ui/calendar.tsx
new file mode 100644
index 00000000000..c4732a1eea1
--- /dev/null
+++ b/banking/src/components/ui/calendar.tsx
@@ -0,0 +1,218 @@
+import * as React from "react"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react"
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+} from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "flex gap-4 flex-col md:flex-row relative",
+ defaultClassNames.months
+ ),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-outline-gray-1 border border-outline-gray-2 shadow-xs has-focus:ring-outline-gray-1/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute bg-surface-modal inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md ps-2 pe-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-ink-gray-5 [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-ink-gray-5 rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn(
+ "select-none w-(--cell-size)",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] select-none text-ink-gray-5",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-e-md group/day aspect-square select-none",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-md"
+ : "[&:first-child[data-selected=true]_button]:rounded-s-md",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "rounded-s-md bg-surface-gray-1",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-e-md bg-surface-gray-1", defaultClassNames.range_end),
+ today: cn(
+ "bg-surface-gray-1 text-ink-gray-8 rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-ink-gray-5 aria-selected:text-ink-gray-5",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-ink-gray-5 opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+ span]:text-xs [&>span]:opacity-70",
+ defaultClassNames.day,
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Calendar, CalendarDayButton }
diff --git a/banking/src/components/ui/card.tsx b/banking/src/components/ui/card.tsx
new file mode 100644
index 00000000000..b674bdeb8f5
--- /dev/null
+++ b/banking/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/banking/src/components/ui/checkbox.tsx b/banking/src/components/ui/checkbox.tsx
new file mode 100644
index 00000000000..46b82379918
--- /dev/null
+++ b/banking/src/components/ui/checkbox.tsx
@@ -0,0 +1,44 @@
+import * as React from "react"
+import { CheckIcon } from "lucide-react"
+import { Checkbox as CheckboxPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ size = "md",
+ ...props
+}: React.ComponentProps & { size?: "sm" | "md" }) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/banking/src/components/ui/command.tsx b/banking/src/components/ui/command.tsx
new file mode 100644
index 00000000000..7ea59800358
--- /dev/null
+++ b/banking/src/components/ui/command.tsx
@@ -0,0 +1,183 @@
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+import { SearchIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandEmpty({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/banking/src/components/ui/dialog.tsx b/banking/src/components/ui/dialog.tsx
new file mode 100644
index 00000000000..1532359875c
--- /dev/null
+++ b/banking/src/components/ui/dialog.tsx
@@ -0,0 +1,156 @@
+import * as React from "react"
+import { XIcon } from "lucide-react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/banking/src/components/ui/direction.tsx b/banking/src/components/ui/direction.tsx
new file mode 100644
index 00000000000..b33710fbf05
--- /dev/null
+++ b/banking/src/components/ui/direction.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Direction } from "radix-ui"
+
+function DirectionProvider({
+ dir,
+ direction,
+ children,
+}: React.ComponentProps & {
+ direction?: React.ComponentProps["dir"]
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+const useDirection = Direction.useDirection
+
+export { DirectionProvider, useDirection }
diff --git a/banking/src/components/ui/dropdown-menu.tsx b/banking/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000000..b66c7b91957
--- /dev/null
+++ b/banking/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,262 @@
+import * as React from "react"
+import { CheckIcon, ChevronRightIcon } from "lucide-react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const BASE_ITEM_STYLES = `outline-hidden select-none relative flex cursor-default items-center
+gap-2 rounded px-2 py-1.5 text-base text-ink-gray-6 data-[variant=destructive]:text-ink-red-3
+data-[variant=destructive]:*:[svg]:text-ink-red-3! [&_svg:not([class*='text-'])]:text-ink-gray-6 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0
+data-disabled:pointer-events-none data-disabled:text-ink-gray-3 data-disabled:*:[svg]:text-ink-gray-3! focus:bg-surface-gray-2 data-inset:ps-8`
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/banking/src/components/ui/empty.tsx b/banking/src/components/ui/empty.tsx
new file mode 100644
index 00000000000..60e6d8f0cba
--- /dev/null
+++ b/banking/src/components/ui/empty.tsx
@@ -0,0 +1,85 @@
+import { cn } from "@/lib/utils"
+
+function Empty({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-ink-gray-7 [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Empty,
+ EmptyHeader,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+ EmptyMedia,
+}
diff --git a/banking/src/components/ui/error-banner.tsx b/banking/src/components/ui/error-banner.tsx
new file mode 100644
index 00000000000..0c57642503a
--- /dev/null
+++ b/banking/src/components/ui/error-banner.tsx
@@ -0,0 +1,64 @@
+import { getErrorMessages } from '@/lib/frappe'
+import { FrappeError } from 'frappe-react-sdk'
+import { Alert, AlertDescription, AlertProps, AlertTitle } from '@/components/ui/alert'
+import { AlertCircle } from 'lucide-react'
+import MarkdownRenderer from '@/components/ui/markdown'
+import _ from '@/lib/translate'
+import { useMemo } from 'react'
+
+type ErrorBannerProps = AlertProps & {
+ error?: FrappeError | null,
+ overrideHeading?: string,
+}
+
+interface ParsedErrorMessage {
+ message: string,
+ title?: string,
+ indicator?: string,
+}
+
+const parseHeading = (message?: ParsedErrorMessage) => {
+ if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
+ return message?.title
+}
+
+const wrapLooseListItemsWithUl = (html: string): string => {
+ // Regex matches consecutive
... blocks not wrapped in
or
+ // It wraps them in a if not already wrapped.
+ return html.replace(/(?:^|[^>])(()+)(?![\s\S]*?<\/ul>)(?![\s\S]*?<\/ol>)/g, (match, p1) => {
+ // Check if the match already has or wrapping (simple check)
+ if (/^/.test(p1) || /^/.test(p1)) {
+ return match // Already wrapped, keep as is
+ }
+ return match.replace(p1, ``)
+ })
+}
+
+const ErrorBanner = ({ error, overrideHeading, ...props }: ErrorBannerProps) => {
+
+
+ //exc_type: "ValidationError" or "PermissionError" etc
+ // exc: With entire traceback - useful for reporting maybe
+ // httpStatus and httpStatusText - not needed
+ // _server_messages: Array of messages - useful for showing to user
+ // console.log(JSON.parse(error?._server_messages!))
+
+ const messages = useMemo(() => {
+ return getErrorMessages(error)
+ }, [error])
+
+ return (
+
+
+ {overrideHeading ?? parseHeading(messages[0])}
+
+ {messages.map((m, i) => {
+ const safeMessage = wrapLooseListItemsWithUl(m.message)
+ return
+ })}
+
+
+ )
+}
+
+export default ErrorBanner
\ No newline at end of file
diff --git a/banking/src/components/ui/file-dropzone.tsx b/banking/src/components/ui/file-dropzone.tsx
new file mode 100644
index 00000000000..ab0c6465c62
--- /dev/null
+++ b/banking/src/components/ui/file-dropzone.tsx
@@ -0,0 +1,289 @@
+import _ from '@/lib/translate'
+import { Dispatch, SetStateAction, useCallback } from 'react'
+import { Accept, useDropzone } from 'react-dropzone'
+import { cn } from '@/lib/utils'
+import { formatBytes, getFileExtension } from '@/lib/file'
+import { Button } from './button'
+import { Trash2Icon } from 'lucide-react'
+
+type Props = {
+ files: File[],
+ setFiles?: Dispatch>
+ accept?: Accept,
+ multiple?: boolean
+ onDrop?: (acceptedFiles: File[]) => void,
+ onUpdate?: VoidFunction
+ className?: string
+}
+
+export const FileDropzone = ({ files, setFiles, accept, multiple = true, onDrop, className, onUpdate }: Props) => {
+
+ const onFileDrop = useCallback((acceptedFiles: File[]) => {
+ // Do something with the files
+ if (multiple) {
+ setFiles?.((prev) => [...prev, ...acceptedFiles])
+ } else {
+ setFiles?.(acceptedFiles)
+ }
+ onDrop?.(acceptedFiles)
+ onUpdate?.()
+
+ }, [setFiles, onDrop, multiple, onUpdate])
+ const { getRootProps, getInputProps } = useDropzone({ onDrop: onFileDrop, accept, multiple })
+ return (
+
+
+ {files.length === 0 ?
{multiple ? _("Drop some files here, or click to select files") : _("Drop a file here, or click to select a file")}
: null}
+
+ {files.map(f =>
+
+
+
+ {f.name}
+ {formatBytes(f.size)}
+
+
+
{
+ e.stopPropagation()
+ setFiles?.(files.filter(file => file.name !== f.name))
+ onUpdate?.()
+ }}>
+
+
+
)}
+
+
+ )
+}
+
+interface FileTypeIconProps {
+ fileType: string
+ size?: 'sm' | 'md' | 'lg' | 'xl'
+ className?: string
+ showBackground?: boolean
+}
+
+const sizeClasses = {
+ sm: 'h-8 w-8',
+ md: 'h-10 w-10',
+ lg: 'h-12 w-12',
+ xl: 'h-16 w-16'
+}
+
+const iconSizeClasses = {
+ sm: 'h-5 w-5',
+ md: 'h-6 w-6',
+ lg: 'h-8 w-8',
+ xl: 'h-10 w-10'
+}
+
+// Special sizing for PowerPoint icon due to different viewBox
+const pptIconSizeClasses = {
+ sm: 'h-3.5 w-3.5',
+ md: 'h-4 w-4',
+ lg: 'h-5 w-5',
+ xl: 'h-6 w-6'
+}
+
+export const FileTypeIcon = ({
+ fileType,
+ size = 'md',
+ className,
+ showBackground = true
+}: FileTypeIconProps) => {
+
+
+ const containerClass = cn(sizeClasses[size], className)
+
+ const RenderIcon = ({ className }: { className?: string }) => {
+ switch (fileType.toLowerCase()) {
+ case 'pdf':
+ return (
+
+
+
+ )
+ case 'doc':
+ case 'docx':
+ return (
+
+
+
+ )
+ case 'xls':
+ case 'xlsx':
+ case 'csv':
+ return (
+
+
+
+
+ )
+ case 'ppt':
+ case 'pptx':
+ return (
+
+
+
+
+
+ )
+ case 'video':
+ case 'mp4':
+ case 'mov':
+ case 'mkv':
+ case 'avi':
+ case 'webm':
+ return (
+
+
+
+ )
+ case 'audio':
+ case 'mp3':
+ case 'wav':
+ case 'ogg':
+ case 'flac':
+ return (
+
+
+
+ )
+ case 'image':
+ case 'jpg':
+ case 'jpeg':
+ case 'png':
+ case 'gif':
+ case 'webp':
+ return (
+
+
+
+
+ )
+ case 'zip':
+ case 'rar':
+ case '7z':
+ case 'tar':
+ case 'gz':
+ return (
+
+
+
+
+ )
+ default:
+ return (
+
+
+
+ )
+ }
+ }
+
+ const getBackgroundColor = () => {
+ switch (fileType.toLowerCase()) {
+ case 'pdf':
+ return 'bg-red-700'
+ case 'doc':
+ case 'docx':
+ return 'bg-[#1A5CBD]'
+ case 'xls':
+ case 'xlsx':
+ case 'csv':
+ return 'bg-green-700'
+ case 'ppt':
+ case 'pptx':
+ return 'bg-[#ED6C47]'
+ case 'video':
+ case 'mp4':
+ case 'mov':
+ case 'mkv':
+ case 'avi':
+ case 'webm':
+ return 'bg-purple-600'
+ case 'audio':
+ case 'mp3':
+ case 'wav':
+ case 'ogg':
+ case 'flac':
+ return 'bg-purple-600'
+ case 'image':
+ case 'jpg':
+ case 'jpeg':
+ case 'png':
+ case 'gif':
+ case 'webp':
+ return 'bg-blue-600'
+ case 'zip':
+ case 'rar':
+ case '7z':
+ case 'tar':
+ case 'gz':
+ return 'bg-yellow-600'
+ default:
+ return 'bg-gray-500'
+ }
+ }
+
+ const getTextColor = () => {
+ switch (fileType.toLowerCase()) {
+ case 'pdf':
+ return 'text-red-700'
+ case 'doc':
+ case 'docx':
+ return 'text-[#1A5CBD]'
+ case 'xls':
+ case 'xlsx':
+ case 'csv':
+ return 'text-green-700 dark:text-green-500'
+ case 'ppt':
+ case 'pptx':
+ return 'text-[#ED6C47]'
+ case 'video':
+ case 'mp4':
+ case 'mov':
+ case 'mkv':
+ case 'avi':
+ case 'webm':
+ return 'text-purple-600'
+ case 'audio':
+ case 'mp3':
+ case 'wav':
+ case 'ogg':
+ case 'flac':
+ return 'text-purple-600'
+ case 'image':
+ case 'jpg':
+ case 'jpeg':
+ case 'png':
+ case 'gif':
+ case 'webp':
+ return 'text-blue-600'
+ case 'zip':
+ case 'rar':
+ case '7z':
+ case 'tar':
+ case 'gz':
+ return 'text-yellow-600'
+ default:
+ return 'text-gray-50'
+ }
+ }
+
+ if (showBackground) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/banking/src/components/ui/form-elements.tsx b/banking/src/components/ui/form-elements.tsx
new file mode 100644
index 00000000000..c29a52ac2bd
--- /dev/null
+++ b/banking/src/components/ui/form-elements.tsx
@@ -0,0 +1,383 @@
+import { FieldValues, RegisterOptions, useFormContext } from "react-hook-form"
+import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormRequiredIndicator, useFormField } from "@/components/ui/form"
+import _ from "@/lib/translate"
+import { Input } from "./input"
+import { ComponentProps, FocusEventHandler, useCallback, useState } from "react"
+import { parseDate } from "chrono-node"
+import { formatDate, getUserDateFormat, toDate } from "@/lib/date"
+import { Popover, PopoverContent, PopoverTrigger } from "./popover"
+import { Button } from "./button"
+import { CalendarIcon } from "lucide-react"
+import { Calendar } from "./calendar"
+import dayjs from "dayjs"
+import { Textarea } from "./textarea"
+import AccountsDropdown, { AccountsDropdownProps } from "../common/AccountsDropdown"
+import PartyTypeDropdown, { PartyTypeDropdownProps } from "../common/PartyTypeDropdown"
+import CurrencyInput from "react-currency-input-field"
+import { getSystemDefault } from "@/lib/frappe"
+import { getCurrencySymbol } from "@/lib/currency"
+import { getCurrencyFormatInfo } from "@/lib/numbers"
+import LinkFieldCombobox, { LinkFieldComboboxProps } from "../common/LinkFieldCombobox"
+import { Select, SelectContent, SelectTrigger, SelectValue } from "./select"
+import { InputGroup, InputGroupAddon } from "./input-group"
+
+interface FormElementProps {
+ name: string,
+ rules?: Omit, "disabled" | "valueAsNumber" | "valueAsDate" | "setValueAs">,
+ label: string,
+ isRequired?: boolean,
+ disabled?: boolean,
+ formDescription?: string,
+ hideLabel?: boolean,
+ readOnly?: boolean,
+
+}
+
+interface DataFieldProps extends FormElementProps {
+ inputProps?: Omit, "value" | "onChange" | "onBlur" | "name" | "ref">
+}
+
+export const DataField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled, readOnly }: DataFieldProps) => {
+
+ const { control } = useFormContext()
+ return (
+
+ {label}{isRequired && }
+
+
+
+ {formDescription && {formDescription} }
+
+
+ )}
+ />
+}
+
+interface SelectFieldProps extends FormElementProps {
+ children: React.ReactNode
+}
+
+export const SelectFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, children, disabled, readOnly }: SelectFieldProps) => {
+
+ const { control } = useFormContext()
+
+ return (
+
+ {label}{isRequired && }
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ {formDescription && {formDescription} }
+
+
+ )}
+ />
+}
+
+interface DateFieldProps extends FormElementProps {
+ inputProps?: Omit, "value" | "onChange" | "onBlur" | "name" | "ref">
+}
+
+export const DateField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled }: DateFieldProps) => {
+
+ const { control } = useFormContext()
+
+ const DatePicker = ({ field }: { field: FieldValues }) => {
+
+ const userDateFormat = getUserDateFormat()
+ const [open, setOpen] = useState(false)
+
+ const [value, setValue] = useState(field.value ? formatDate(field.value) : undefined)
+
+ const date = field.value ? toDate(field.value) : undefined
+
+ return
+
+ {
+ setValue(formatDate(field.value))
+ field.onBlur()
+ }}
+ placeholder={userDateFormat}
+ value={value}
+ onChange={(e) => {
+ setValue(e.target.value)
+ if (e.target.value) {
+ // On change in value, try computing date usning standard formats first
+ const dateObj = toDate(e.target.value, userDateFormat)
+ // If we find a valid date, use it
+ if (dateObj && !isNaN(dateObj.getTime())) {
+ field.onChange(formatDate(dateObj, "YYYY-MM-DD"))
+ } else {
+ // If not, try parsing using chrono-node for things like "1st July 2025"
+ const date = parseDate(e.target.value)
+ if (date) {
+ field.onChange(formatDate(date, "YYYY-MM-DD"))
+ }
+ }
+ } else {
+ field.onChange("")
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault()
+ setOpen(true)
+ }
+ }}
+ maxLength={140}
+ {...inputProps} />
+
+
+
+
+
+ {_("Select date")}
+
+
+
+ {
+ setValue(formatDate(date))
+ field.onChange(formatDate(date, "YYYY-MM-DD"))
+ setOpen(false)
+ }}
+ />
+
+
+
+ }
+
+ return (
+
+ {label}{isRequired && }
+
+ {formDescription && {formDescription} }
+
+
+ )}
+ />
+}
+
+
+interface SmallTextFieldProps extends FormElementProps {
+ inputProps?: Omit, "value" | "onChange" | "onBlur" | "name" | "ref">
+}
+
+export const SmallTextField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled, readOnly }: SmallTextFieldProps) => {
+
+ const { control } = useFormContext()
+ return (
+
+ {label}{isRequired && }
+
+
+
+ {formDescription && {formDescription} }
+
+
+ )}
+ />
+}
+
+
+interface AccountFormFieldProps extends Omit, FormElementProps {
+}
+export const AccountFormField = (props: AccountFormFieldProps) => {
+
+ const { control } = useFormContext()
+
+ return (
+
+ {props.label}{props.isRequired && }
+
+ {props.formDescription && {props.formDescription} }
+
+
+ )}
+ />
+}
+
+interface PartyTypeFormField extends FormElementProps {
+ inputProps?: Omit
+}
+
+export const PartyTypeFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, inputProps, disabled, readOnly }: PartyTypeFormField) => {
+
+ const { control } = useFormContext()
+
+ return (
+
+ {label}{isRequired && }
+
+ {formDescription && {formDescription} }
+
+
+ )}
+ />
+}
+
+
+interface CurrencyFormFieldProps extends FormElementProps {
+ currency?: string,
+ style?: React.CSSProperties,
+ leftSlot?: React.ReactNode,
+}
+
+export const CurrencyFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, currency, disabled, readOnly, style = {}, leftSlot }: CurrencyFormFieldProps) => {
+
+ const { control } = useFormContext()
+
+ const defaultCurrency = getSystemDefault("currency")
+ const currencySymbol = getCurrencySymbol(currency ?? defaultCurrency)
+
+
+ const CurrencyField = ({ field }: { field: FieldValues }) => {
+
+ const onFocus: FocusEventHandler = useCallback((e) => {
+ // When the input is focused, select the text
+ // A short timeout is needed so that the input selects the text after the focus event
+ setTimeout(() => {
+ // Check if the input is focused - do not select text if the input is not focused
+ if (e.target.contains(document.activeElement)) {
+ e.target.select()
+ }
+ }, 100)
+ }, [])
+
+ const { formItemId } = useFormField()
+
+ // Get the correct separators for the currency
+ const formatInfo = getCurrencyFormatInfo(currency ?? defaultCurrency)
+ const groupSeparator = formatInfo.group_sep || ","
+ const decimalSeparator = formatInfo.decimal_str || "."
+
+ return {
+ // 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 ?? ''
+ field.onChange(newValue)
+ }}
+ customInput={Input}
+ />
+ }
+
+ return (
+
+ {label}{isRequired && }
+
+
+
+ {leftSlot && {leftSlot} }
+
+
+
+
+ {formDescription && {formDescription} }
+
+
+ )}
+ />
+}
+
+interface LinkFormFieldProps extends FormElementProps, Omit {
+}
+
+export const LinkFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, disabled, readOnly, ...inputProps }: LinkFormFieldProps) => {
+
+ const { control } = useFormContext()
+
+ return (
+
+ {label}{isRequired && }
+
+ {formDescription && {formDescription} }
+
+
+ )}
+ />
+}
\ No newline at end of file
diff --git a/banking/src/components/ui/form.tsx b/banking/src/components/ui/form.tsx
new file mode 100644
index 00000000000..a29fa175eff
--- /dev/null
+++ b/banking/src/components/ui/form.tsx
@@ -0,0 +1,174 @@
+import * as React from "react"
+import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"
+
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ useFormState,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState } = useFormContext()
+ const formState = useFormState({ name: fieldContext.name })
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+}
+
+function FormLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormRequiredIndicator({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ *
+
+ )
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : props.children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+ FormRequiredIndicator,
+}
diff --git a/banking/src/components/ui/hover-card.tsx b/banking/src/components/ui/hover-card.tsx
new file mode 100644
index 00000000000..e4166a61ff0
--- /dev/null
+++ b/banking/src/components/ui/hover-card.tsx
@@ -0,0 +1,42 @@
+import * as React from "react"
+import { HoverCard as HoverCardPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function HoverCard({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function HoverCardTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function HoverCardContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }
diff --git a/banking/src/components/ui/input-group.tsx b/banking/src/components/ui/input-group.tsx
new file mode 100644
index 00000000000..52ba97d4fd3
--- /dev/null
+++ b/banking/src/components/ui/input-group.tsx
@@ -0,0 +1,161 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+const inputGroupVariants = cva(cn("group/input-group relative flex w-full items-center outline-none min-w-0 border border-transparent transition-all",
+
+ // Variants based on alignment.
+ "has-[>[data-align=inline-start]]:[&>input]:ps-2",
+ "has-[>[data-align=inline-end]]:[&>input]:pe-2",
+ "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
+ "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
+
+
+ // Focus state.
+ "has-[[data-slot=input]:focus-visible]:bg-surface-white has-[[data-slot=input]:focus-visible]:border-outline-gray-4 has-[[data-slot=input]:focus-visible]:shadow-focus-gray",
+
+ // Disabled state
+ "has-[>[data-slot=input]:disabled]:bg-surface-gray-1 has-[>[data-slot=input]:disabled]:text-ink-gray-3 has-[>[data-slot=input]:disabled]:cursor-not-allowed has-[>[data-slot=input]:disabled]:pointer-events-none",
+
+ // Error state.
+ "has-[[data-slot][aria-invalid=true]]:shadow-focus-red has-[[data-slot][aria-invalid=true]]:border-outline-red-3",
+
+ // Read only state
+ "has-[[data-slot][aria-readonly=true]]:bg-surface-gray-1 has-[[data-slot][aria-readonly=true]]:text-ink-gray-6 has-[[data-slot][aria-readonly=true]]:pointer-events-none",
+
+),
+ {
+ variants: {
+ variant: {
+ subtle: "bg-surface-gray-2",
+ outline: "bg-surface-white border-outline-gray-2"
+ },
+ size: {
+ sm: "h-7 has-[>textarea]:h-auto rounded text-base",
+ md: "h-8 has-[>textarea]:h-auto rounded text-base",
+ lg: "h-10 has-[>textarea]:h-auto rounded-md text-lg"
+ }
+ },
+ defaultVariants: {
+ variant: "subtle",
+ size: "md"
+ }
+ }
+)
+
+function InputGroup({ className, variant = "subtle", size = "md", ...props }: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "text-ink-gray-5 flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
+ {
+ variants: {
+ align: {
+ "inline-start":
+ "order-first ps-3 has-[>button]:ms-[-0.45rem] has-[>kbd]:ms-[-0.35rem]",
+ "inline-end":
+ "order-last pe-3 has-[>button]:me-[-0.45rem] has-[>kbd]:me-[-0.35rem]",
+ "block-start":
+ "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
+ "block-end":
+ "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ }
+)
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
+ }}
+ {...props}
+ />
+ )
+}
+
+const inputGroupButtonVariants = cva(
+ "text-sm shadow-none flex gap-2 items-center",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
+ sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+ }
+)
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size"> &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+}
diff --git a/banking/src/components/ui/input.tsx b/banking/src/components/ui/input.tsx
new file mode 100644
index 00000000000..66a923f99b2
--- /dev/null
+++ b/banking/src/components/ui/input.tsx
@@ -0,0 +1,49 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+import { cva, VariantProps } from "class-variance-authority"
+
+const inputVariants = cva(cn("flex w-full min-w-0 transition-all outline-none border border-transparent",
+ "focus-visible:bg-surface-white focus-visible:border-outline-gray-4 focus-visible:shadow-focus-gray",
+ "active:bg-surface-white active:shadow-sm active:border-outline-gray-4",
+ "placeholder:text-ink-gray-4 text-ink-gray-7",
+ "disabled:bg-surface-gray-1 disabled:placeholder:text-ink-gray-3 disabled:text-ink-gray-3 disabled:cursor-not-allowed disabled:pointer-events-none",
+ "aria-readonly:bg-surface-gray-1 aria-readonly:text-ink-gray-6 aria-readonly:pointer-events-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
+ "in-data-[slot=input-group]:border-transparent! in-data-[slot=input-group]:focus-visible:shadow-none! in-data-[slot=input-group]:bg-transparent!"),
+ {
+ variants: {
+ inputSize: {
+ sm: "text-base rounded py-1.5 px-2 h-7",
+ md: "text-base rounded py-2 px-2.5 h-8",
+ lg: "text-lg rounded-md py-[11px] px-3 h-10",
+ },
+ variant: {
+ subtle: "bg-surface-gray-2 hover:bg-surface-gray-3 aria-invalid:bg-surface-red-1",
+ outline: "bg-surface-white border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 disabled:border-outline-gray-2",
+ }
+ },
+ defaultVariants: {
+ inputSize: "md",
+ variant: "subtle"
+ }
+ }
+)
+
+function Input({ className, type, inputSize = "md", variant = "subtle", ...props }: React.ComponentProps<"input"> & VariantProps) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/banking/src/components/ui/kbd.tsx b/banking/src/components/ui/kbd.tsx
new file mode 100644
index 00000000000..b16492eec0b
--- /dev/null
+++ b/banking/src/components/ui/kbd.tsx
@@ -0,0 +1,28 @@
+import { cn } from "@/lib/utils"
+
+function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
+ return (
+
+ )
+}
+
+function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Kbd, KbdGroup }
diff --git a/banking/src/components/ui/keyboard-keys.tsx b/banking/src/components/ui/keyboard-keys.tsx
new file mode 100644
index 00000000000..4c1d2a099d9
--- /dev/null
+++ b/banking/src/components/ui/keyboard-keys.tsx
@@ -0,0 +1,8 @@
+
+export const KeyboardMetaKeyIcon = () => {
+ if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
+ return ⌘
+ } else {
+ return Ctrl
+ }
+}
\ No newline at end of file
diff --git a/banking/src/components/ui/label.tsx b/banking/src/components/ui/label.tsx
new file mode 100644
index 00000000000..72e984af524
--- /dev/null
+++ b/banking/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/banking/src/components/ui/list-view.tsx b/banking/src/components/ui/list-view.tsx
new file mode 100644
index 00000000000..f2d5759d5aa
--- /dev/null
+++ b/banking/src/components/ui/list-view.tsx
@@ -0,0 +1,510 @@
+import * as React from "react"
+import {
+ type Cell,
+ type ColumnDef,
+ type ColumnSizingState,
+ type Header,
+ type OnChangeFn,
+ type Row,
+ type RowSelectionState,
+ flexRender,
+ functionalUpdate,
+ getCoreRowModel,
+ useReactTable,
+} from "@tanstack/react-table"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import { useDebounceCallback } from "usehooks-ts"
+
+import { Checkbox } from "@/components/ui/checkbox"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { cn } from "@/lib/utils"
+import { useDirection } from "./direction"
+
+/** Optional per-column layout hints for `ListView`. */
+export type ListViewColumnMeta = {
+ /** CSS grid track (`1fr`, `2fr`, `minmax(0,1fr)`). When set, used instead of TanStack pixel `size` in `grid-template-columns`. */
+ gridWidth?: string
+ align?: "left" | "center" | "right"
+ /**
+ * Tabular figures for stable digit width. Default: on when `align` is `right` (amounts); set `false` to opt out, or `true` for dates/IDs.
+ */
+ tabularNums?: boolean
+ /**
+ * Full text for an overflow tooltip (shown only when the cell truncates). If omitted, a string `accessorKey` value is used when available.
+ */
+ getTooltipText?: (row: unknown) => string | null | undefined
+ /** `false` disables the overflow tooltip for this column. */
+ truncateTooltip?: boolean
+ /**
+ * `false` skips single-line truncation for cells with custom layouts (e.g. action buttons). Default `true`.
+ */
+ truncate?: boolean
+}
+
+function alignClass(meta: ListViewColumnMeta | undefined) {
+ switch (meta?.align) {
+ case "center":
+ return "justify-center text-center"
+ case "right":
+ return "justify-end text-end"
+ default:
+ return "justify-start text-start"
+ }
+}
+
+function tabularNumsClass(meta: ListViewColumnMeta | undefined) {
+ if (meta?.tabularNums === false) return ""
+ if (meta?.tabularNums === true) return "tabular-nums"
+ if (meta?.align === "right") return "tabular-nums"
+ return ""
+}
+
+function resolveTooltipLabel(
+ row: Row,
+ meta: ListViewColumnMeta | undefined,
+ columnDef: ColumnDef,
+): string | undefined {
+ if (meta?.truncateTooltip === false) return undefined
+ const fromMeta = meta?.getTooltipText?.(row.original as unknown)
+ if (fromMeta != null && String(fromMeta).length > 0) {
+ return String(fromMeta)
+ }
+ const key = "accessorKey" in columnDef ? columnDef.accessorKey : undefined
+ if (key !== undefined && key !== null && key !== "") {
+ try {
+ const v = row.getValue(String(key))
+ if (v != null && v !== "") return String(v)
+ } catch {
+ /* column may not expose a value */
+ }
+ }
+ return undefined
+}
+
+function ListViewCellBody({
+ cell,
+ row,
+ meta,
+ children,
+}: {
+ cell: Cell
+ row: Row
+ meta: ListViewColumnMeta | undefined
+ children: React.ReactNode
+}) {
+ const ref = React.useRef(null)
+ const [overflowing, setOverflowing] = React.useState(false)
+ const direction = useDirection()
+
+ const tooltipLabel = resolveTooltipLabel(row, meta, cell.column.columnDef)
+ const tooltipAlign = meta?.align === "right" && direction === "ltr" ? "end" : "start"
+
+ const measure = React.useCallback(() => {
+ const el = ref.current
+ if (!el) return
+ setOverflowing(el.scrollWidth > el.clientWidth + 1)
+ }, [])
+
+ React.useLayoutEffect(() => {
+ measure()
+ }, [measure, children, tooltipLabel])
+
+ React.useEffect(() => {
+ const el = ref.current
+ if (!el || typeof ResizeObserver === "undefined") return
+ const ro = new ResizeObserver(measure)
+ ro.observe(el)
+ return () => ro.disconnect()
+ }, [measure])
+
+ if (meta?.truncate === false) {
+ return {children}
+ }
+
+ const inner = (
+
+ {children}
+
+ )
+
+ if (!tooltipLabel || !overflowing) {
+ return inner
+ }
+
+ return (
+
+ {inner}
+
+ {tooltipLabel}
+
+
+ )
+}
+
+function gridTemplateFromHeaders(headers: Header[]) {
+ return headers
+ .map((header) => {
+ const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
+ if (meta?.gridWidth) {
+ return meta.gridWidth
+ }
+ return `${header.getSize()}px`
+ })
+ .join(" ")
+}
+
+function defaultGetRowId(row: TData, index: number) {
+ const r = row as Record
+ if (r && typeof r.name === "string") return r.name
+ if (r && typeof r.id === "string") return r.id
+ return String(index)
+}
+
+export type ListViewProps = {
+ data: TData[]
+ columns: ColumnDef[]
+ /**
+ * Stable row id for selection and keys. Defaults to `name`, then `id`, then row index (index is fragile if data order changes).
+ */
+ getRowId?: (originalRow: TData, index: number) => string
+ /** Pixel height of each body row (default 40, matches frappe-ui ListView). */
+ rowHeight?: number
+ className?: string
+ /** Classes for the scrollable viewport (default includes max-height). */
+ scrollAreaClassName?: string
+ /** Max height of the scroll area; number is pixels. Default `420`. */
+ maxHeight?: number | string
+ emptyState?: React.ReactNode
+ enableColumnResizing?: boolean
+ columnSizing?: ColumnSizingState
+ onColumnSizingChange?: OnChangeFn
+ /** Debounced callback for persisting widths (e.g. localStorage). */
+ onColumnSizingCommit?: (sizing: ColumnSizingState) => void
+ columnSizingCommitDelayMs?: number
+ enableRowSelection?: boolean
+ rowSelection?: RowSelectionState
+ onRowSelectionChange?: OnChangeFn
+ onRowClick?: (row: TData, event: React.MouseEvent) => void
+}
+
+function ListViewInner({
+ data,
+ columns: userColumns,
+ getRowId: getRowIdProp,
+ rowHeight = 40,
+ className,
+ scrollAreaClassName,
+ maxHeight = 420,
+ emptyState,
+ enableColumnResizing = true,
+ columnSizing: controlledColumnSizing,
+ onColumnSizingChange: controlledOnColumnSizingChange,
+ onColumnSizingCommit,
+ columnSizingCommitDelayMs = 250,
+ enableRowSelection = false,
+ rowSelection: controlledRowSelection,
+ onRowSelectionChange: controlledOnRowSelectionChange,
+ onRowClick,
+}: ListViewProps) {
+ const parentRef = React.useRef(null)
+
+ const [internalColumnSizing, setInternalColumnSizing] = React.useState({})
+ const columnSizing = controlledColumnSizing ?? internalColumnSizing
+
+ const [internalRowSelection, setInternalRowSelection] = React.useState({})
+ const rowSelection = controlledRowSelection ?? internalRowSelection
+ const setRowSelection = controlledOnRowSelectionChange ?? setInternalRowSelection
+
+ const debouncedSizingCommit = useDebounceCallback(
+ (sizing: ColumnSizingState) => {
+ onColumnSizingCommit?.(sizing)
+ },
+ columnSizingCommitDelayMs,
+ )
+
+ const selectionColumn = React.useMemo>(
+ () => ({
+ id: "__list_view_select__",
+ size: 36,
+ minSize: 36,
+ maxSize: 36,
+ enableResizing: false,
+ meta: {
+ truncate: false,
+ truncateTooltip: false,
+ } satisfies ListViewColumnMeta,
+ header: ({ table }) => (
+
+ table.toggleAllRowsSelected(value === true)}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ ),
+ cell: ({ row }) => (
+
+ row.toggleSelected(value === true)}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ ),
+ }),
+ [],
+ )
+
+ const columns = React.useMemo(() => {
+ if (!enableRowSelection) return userColumns
+ return [selectionColumn, ...userColumns]
+ }, [enableRowSelection, selectionColumn, userColumns])
+
+ const getRowId = React.useCallback(
+ (originalRow: TData, index: number) =>
+ (getRowIdProp ?? defaultGetRowId)(originalRow, index),
+ [getRowIdProp],
+ )
+
+ const onColumnSizingChangeInternal = React.useCallback>(
+ (updater) => {
+ if (controlledOnColumnSizingChange) {
+ controlledOnColumnSizingChange(updater)
+ return
+ }
+ setInternalColumnSizing((old) => {
+ const next = functionalUpdate(updater, old)
+ debouncedSizingCommit(next)
+ return next
+ })
+ },
+ [controlledOnColumnSizingChange, debouncedSizingCommit],
+ )
+
+ const direction = useDirection()
+
+ const table = useReactTable({
+ data,
+ columns,
+ defaultColumn: {
+ minSize: 50,
+ size: 150,
+ },
+ columnResizeMode: "onChange",
+ columnResizeDirection: direction,
+ enableColumnResizing,
+ getCoreRowModel: getCoreRowModel(),
+ getRowId,
+ onColumnSizingChange: onColumnSizingChangeInternal,
+ onRowSelectionChange: setRowSelection,
+ state: {
+ columnSizing,
+ rowSelection,
+ },
+ enableRowSelection,
+ })
+
+ const headerGroup = table.getHeaderGroups()[0]
+ const gridTemplateColumns = headerGroup
+ ? gridTemplateFromHeaders(headerGroup.headers)
+ : ""
+
+ const { rows } = table.getRowModel()
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => rowHeight,
+ overscan: 10,
+ })
+
+ const maxHeightStyle =
+ typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight
+
+ if (data.length === 0) {
+ return (
+
+ {emptyState ?? "No data"}
+
+ )
+ }
+
+ /** Tracks + column gaps + horizontal padding (`px-2` × 2) so header and body share one scroll width. */
+ const colCount = headerGroup?.headers.length ?? 0
+ const minTableOuterWidth =
+ table.getCenterTotalSize() +
+ Math.max(0, colCount - 1) * 16 +
+ 16
+
+ return (
+
+
+ {headerGroup ? (
+
+ {headerGroup.headers.map((header) => {
+ const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
+ return (
+
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ {enableColumnResizing && header.column.getCanResize() ? (
+ <>
+
+
{
+ e.preventDefault()
+ document.body.classList.add("select-none", "cursor-col-resize")
+ const end = () => {
+ document.body.classList.remove("select-none", "cursor-col-resize")
+ window.removeEventListener("mouseup", end)
+ window.removeEventListener("touchend", end)
+ }
+ window.addEventListener("mouseup", end)
+ window.addEventListener("touchend", end)
+ header.getResizeHandler()(e)
+ }}
+ onTouchStart={header.getResizeHandler()}
+ className="absolute top-0 ltr:right-0 rtl:left-0 z-10 h-full w-2 max-w-[12px] cursor-col-resize touch-none select-none bg-transparent"
+ />
+ >
+ ) : null}
+
+ )
+ })}
+
+ ) : null}
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index]
+ if (!row) return null
+ const leadDataColumnIndex = enableRowSelection ? 1 : 0
+ return (
+
0 && "border-t border-outline-gray-1",
+ !row.getIsSelected() && "hover:bg-surface-menu-bar",
+ row.getIsSelected() && "bg-surface-gray-2 hover:bg-surface-gray-3",
+ onRowClick && "cursor-pointer",
+ )}
+ style={{
+ display: "grid",
+ gridTemplateColumns,
+ boxSizing: "border-box",
+ columnGap: "1rem",
+ height: `${rowHeight}px`,
+ transform: `translateY(${virtualRow.start}px)`,
+ }}
+ onClick={(e) => {
+ if (onRowClick) onRowClick(row.original, e)
+ }}
+ >
+ {virtualRow.index > 0 &&
}
+ {row.getVisibleCells().map((cell, cellIndex) => {
+ const meta = cell.column.columnDef.meta as ListViewColumnMeta | undefined
+ return (
+
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+
+ )
+ })}
+
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+/**
+ * Div-based list with CSS Grid columns, optional resize handles, row virtualization, and frappe-ui–aligned Espresso tokens.
+ */
+export function ListView
(props: ListViewProps) {
+ return
+}
+
+export type { ColumnSizingState, RowSelectionState }
diff --git a/banking/src/components/ui/loaders.tsx b/banking/src/components/ui/loaders.tsx
new file mode 100644
index 00000000000..937e11bcd37
--- /dev/null
+++ b/banking/src/components/ui/loaders.tsx
@@ -0,0 +1,27 @@
+import { Skeleton } from "./skeleton"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table"
+
+export const TableLoader = ({ rows = 10, columns = 5 }: { rows?: number, columns?: number }) => {
+ return
+
+
+ {Array.from({ length: columns }).map((_, index) => (
+
+
+
+ ))}
+
+
+
+ {Array.from({ length: rows }).map((_, index) => (
+
+ {Array.from({ length: columns }).map((_, index) => (
+
+
+
+ ))}
+
+ ))}
+
+
+}
\ No newline at end of file
diff --git a/banking/src/components/ui/markdown.tsx b/banking/src/components/ui/markdown.tsx
new file mode 100644
index 00000000000..562d4611936
--- /dev/null
+++ b/banking/src/components/ui/markdown.tsx
@@ -0,0 +1,28 @@
+import React from 'react'
+import rehypeRaw from 'rehype-raw'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+// import './markdown.css'
+
+interface MarkdownRendererProps {
+ content: string,
+ className?: string
+}
+
+const MarkdownRenderer: React.FC = ({ content }) => {
+ return ,
+ // ul: (props) => ,
+ // ol: (props) => ,
+ // li: (props) => ,
+ // a: (props) => ,
+ // }}>
+ >
+ {content}
+
+}
+
+export default MarkdownRenderer
\ No newline at end of file
diff --git a/banking/src/components/ui/popover.tsx b/banking/src/components/ui/popover.tsx
new file mode 100644
index 00000000000..ba9a2110a8b
--- /dev/null
+++ b/banking/src/components/ui/popover.tsx
@@ -0,0 +1,87 @@
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: React.ComponentProps<"p">) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+ PopoverAnchor,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverDescription,
+}
diff --git a/banking/src/components/ui/progress.tsx b/banking/src/components/ui/progress.tsx
new file mode 100644
index 00000000000..53f3cc4dc54
--- /dev/null
+++ b/banking/src/components/ui/progress.tsx
@@ -0,0 +1,67 @@
+import * as React from "react"
+import { Progress as ProgressPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { cva, VariantProps } from "class-variance-authority"
+
+const progressVariants = cva(
+ "bg-surface-gray-2 relative w-full overflow-hidden rounded-full",
+ {
+ variants: {
+ size: {
+ sm: "h-0.5",
+ md: "h-1",
+ lg: "h-2.5",
+ xl: "h-3"
+ }
+ }
+ }
+)
+
+interface ProgressProps extends React.ComponentProps, VariantProps {
+ /** Optional text label displayed on the progress bar */
+ label?: React.ReactNode,
+ /** Whether to show a hint/tooltip for the progress value */
+ hint?: boolean,
+ /** Override the default hint text with custom progress value */
+ hintText?: React.ReactNode
+}
+
+function Progress({
+ className,
+ value,
+ size = "sm",
+ label,
+ hint,
+ hintText,
+ ...props
+}: ProgressProps) {
+
+ const progressValue = hintText ? hintText : `${value}%`
+
+ return (
+
+ {label || hint ?
+ {label && {label} }
+ {hint && {progressValue} }
+
: null}
+
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/banking/src/components/ui/radio-group.tsx b/banking/src/components/ui/radio-group.tsx
new file mode 100644
index 00000000000..ab4b24379d3
--- /dev/null
+++ b/banking/src/components/ui/radio-group.tsx
@@ -0,0 +1,43 @@
+import * as React from "react"
+import { CircleIcon } from "lucide-react"
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function RadioGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/banking/src/components/ui/select.tsx b/banking/src/components/ui/select.tsx
new file mode 100644
index 00000000000..a77bed46f81
--- /dev/null
+++ b/banking/src/components/ui/select.tsx
@@ -0,0 +1,221 @@
+import * as React from "react"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { cva, VariantProps } from "class-variance-authority"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+
+const selectVariants = cva(cn("flex w-fit items-center justify-between gap-2 min-w-0 transition-all outline-none border border-transparent whitespace-nowrap",
+ "focus-visible:bg-surface-white focus-visible:border-outline-gray-4 focus-visible:shadow-focus-gray",
+ "active:bg-surface-white active:shadow-sm active:border-outline-gray-4 data-[state=open]:border-outline-gray-4",
+ "placeholder:text-ink-gray-4 text-ink-gray-7",
+ "disabled:bg-surface-gray-1 disabled:placeholder:text-ink-gray-3 disabled:text-ink-gray-3 disabled:cursor-not-allowed disabled:pointer-events-none",
+ "aria-readonly:bg-surface-gray-1 aria-readonly:text-ink-gray-6 aria-readonly:pointer-events-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
+ // Disable most styles inside an input group
+ "in-data-[slot=input-group]:border-transparent! in-data-[slot=input-group]:focus-visible:shadow-none! in-data-[slot=input-group]:bg-transparent!"),
+ {
+ variants: {
+ inputSize: {
+ sm: "text-base rounded py-1.5 px-2 h-7",
+ md: "text-base rounded py-2 px-2.5 h-8",
+ lg: "text-lg rounded-md py-[11px] px-3 h-10",
+ },
+ variant: {
+ subtle: "bg-surface-gray-2 hover:bg-surface-gray-3 aria-invalid:bg-surface-red-1",
+ outline: "bg-surface-white border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 disabled:border-outline-gray-2",
+ }
+ },
+ defaultVariants: {
+ inputSize: "md",
+ variant: "subtle"
+ }
+ }
+)
+
+function SelectTrigger({
+ className,
+ inputSize = "md",
+ variant = "subtle",
+ children,
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/banking/src/components/ui/separator.tsx b/banking/src/components/ui/separator.tsx
new file mode 100644
index 00000000000..8339d5a5bb9
--- /dev/null
+++ b/banking/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/banking/src/components/ui/settings-dialog.tsx b/banking/src/components/ui/settings-dialog.tsx
new file mode 100644
index 00000000000..18681a93cf8
--- /dev/null
+++ b/banking/src/components/ui/settings-dialog.tsx
@@ -0,0 +1,273 @@
+import * as React from "react"
+import { Tabs as TabsPrimitive, Dialog as DialogPrimitive } from "radix-ui"
+import { cn } from "@/lib/utils"
+import { DialogContent } from "./dialog"
+
+/**
+ * Sample Usage:
+ *
+ *
+ *
+ * ...your content...
+ *
+ *
+ * setOpen(false)} defaultValue="preferences">
+ *
+ *
+ * } label="Preferences" value="preferences" />
+ * } label="Matching Rules" value="rules" />
+ *
+ *
+ * } label="Bank Accounts" value="bank-accounts" />
+ * } label="Masters" value="masters" />
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+
+type SettingsDialogContextValue = {
+ onClose?: VoidFunction
+}
+
+const SettingsDialogContext = React.createContext({})
+
+/**
+ * Exposes `onClose` to descendant panels so they can dismiss the dialog after
+ * a successful save without prop-drilling.
+ */
+export const useSettingsDialog = () => React.useContext(SettingsDialogContext)
+
+type SettingsDialogProps = Omit<
+ React.ComponentProps,
+ "orientation"
+> & {
+ onClose?: VoidFunction
+ contentClassName?: string
+}
+
+function SettingsDialog({
+ children,
+ className,
+ contentClassName,
+ onClose,
+ ...props
+}: SettingsDialogProps) {
+ const contextValue = React.useMemo(() => ({ onClose }), [onClose])
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function SettingsTabs({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+type SettingsTabGroupProps = React.ComponentProps<"div"> & {
+ header?: React.ReactNode
+}
+
+function SettingsTabGroup({
+ children,
+ header,
+ className,
+ ...props
+}: SettingsTabGroupProps) {
+ return (
+
+ {header && (
+
+ {header}
+
+ )}
+
{children}
+
+
+ )
+}
+
+type SettingsTabItemProps = React.ComponentProps & {
+ icon?: React.ReactNode
+ label: React.ReactNode
+}
+
+function SettingsTabItem({
+ icon,
+ label,
+ className,
+ ...props
+}: SettingsTabItemProps) {
+ return (
+
+