diff --git a/banking/eslint.config.js b/banking/eslint.config.js index 9cc2a204656..c5ac24c55e1 100644 --- a/banking/eslint.config.js +++ b/banking/eslint.config.js @@ -9,16 +9,18 @@ export default defineConfig([ globalIgnores(["dist"]), { files: ["**/*.{ts,tsx}"], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], + extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite], + plugins: { + "react-hooks": reactHooks, + }, languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, - onlyExportComponents: false, + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react-refresh/only-export-components": "off", + }, }, ]); diff --git a/banking/package.json b/banking/package.json index af48320c76f..f6c5971cd03 100644 --- a/banking/package.json +++ b/banking/package.json @@ -41,7 +41,6 @@ "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", diff --git a/banking/proxyOptions.ts b/banking/proxyOptions.ts index e1aeca81094..f549deebec2 100644 --- a/banking/proxyOptions.ts +++ b/banking/proxyOptions.ts @@ -1,13 +1,17 @@ -const common_site_config = require('../../../sites/common_site_config.json'); +import { readFileSync } from 'node:fs'; + +const common_site_config = JSON.parse( + readFileSync(new URL('../../../sites/common_site_config.json', import.meta.url), 'utf8') +) as { webserver_port: string | number }; 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}`; + router: function (req) { + const site_name = req.headers?.host?.split(':')[0]; + return `http://${site_name ?? 'localhost'}:${webserver_port}`; } } }; diff --git a/banking/src/App.tsx b/banking/src/App.tsx index 2e6f8339ce9..b46c5ba4233 100644 --- a/banking/src/App.tsx +++ b/banking/src/App.tsx @@ -1,14 +1,15 @@ -import { useEffect } from 'react' +import { lazy, 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 BankStatementImporterContainer from '@/pages/BankStatementImporterContainer' 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' + +const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter')) +const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog')) function App() { useEffect(() => { @@ -43,7 +44,6 @@ function App() { > {window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' && - } /> }> diff --git a/banking/src/components/features/ActionLog/ActionLog.tsx b/banking/src/components/features/ActionLog/ActionLog.tsx index e8ed9ae234a..b260a4b6cca 100644 --- a/banking/src/components/features/ActionLog/ActionLog.tsx +++ b/banking/src/components/features/ActionLog/ActionLog.tsx @@ -1,475 +1,42 @@ import { Button } from '@/components/ui/button' -import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Dialog, 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 { HistoryIcon } from 'lucide-react' +import { useState } from 'react' 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' +import ActionLogDialog from './ActionLogDialog' const ActionLog = () => { + const [isOpen, setIsOpen] = useState(false) - const [isOpen, setIsOpen] = useState(false) + useHotkeys('meta+z', () => { + setIsOpen(true) + }, { + enabled: true, + enableOnFormTags: false, + preventDefault: true + }) - useHotkeys('meta+z', () => { - setIsOpen(true) - }, { - enabled: true, - enableOnFormTags: false, - preventDefault: true - }) - - return ( - - - - - - - - - {_("Reconciliation History")} - - - - - {_("Reconciliation History")} - {_("View all reconciliation actions taken in this session.")} - - - - - - - - - - ) + return ( + + + + + + + + + {_("Reconciliation History")} + + + {isOpen && ( + setIsOpen(false)} /> + )} + + ) } -const ActionLogDialogContent = () => { - - const actionLog = useAtomValue(bankRecActionLog) - - return
- {actionLog.map((action) => ( -
- -
-
-
- {action.items.map((item, index) => ( - - ))} -
-
-
-
- ))} - - {actionLog.length === 0 && - - - - - {_("No reconciliation actions found")} - {_("You have not performed any reconciliations in this session yet.")} - - } -
-} - - - -const ActionGroupHeader = ({ action }: { action: ActionLogType }) => { - - const label = useMemo(() => { - switch (action.type) { - case 'match': - return _("Matched") - case 'payment': - if (action.isBulk) { - return _("Bulk Payment") - } - return _("Payment") - - case 'transfer': - if (action.isBulk) { - return _("Bulk Transfer") - } - return _("Transfer") - - case 'bank_entry': - if (action.isBulk) { - return _("Bulk Bank Entry") - } - return _("Bank Entry") - - default: - return _("Action") - } - }, [action]) - - return
- {action.type === 'match' && } - {action.type === 'payment' && } - {action.type === 'transfer' && } - {action.type === 'bank_entry' && } - - {label} - {dayjs(action.timestamp).fromNow()} - -
-} - -const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => { - - const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 - - const { banks } = useGetBankAccounts() - - const bank = useMemo(() => { - if (item.bankTransaction.bank_account) { - return banks?.find((bank) => bank.name === item.bankTransaction.bank_account) - } - return null - }, [item.bankTransaction.bank_account, banks]) - - const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit - - const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '') - - return
-
-
-
-

{item.bankTransaction.description}

-
-
- - {item.bankTransaction.bank_account} -
- -
- - {formatDate(item.bankTransaction.date, 'Do MMM YYYY')} -
- -
-
- {isWithdrawal ? : } - {formatCurrency(amount, currency)} -
-
-
-
-
-
- - {["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name} - - {item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && } - {item.voucher.reference_doctype === "Journal Entry" && } -
-
-
-
-
- -
-
-} - -const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { - - return
- - -
-} - -const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { - - const accounts = useMemo(() => { - - const allAccounts = (item.voucher.doc as JournalEntry).accounts - - return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true) - - }, [item, bank]) - - return <> - {accounts.length === 1 ? {accounts[0].account} : - - - {_("Split across {} accounts", [accounts.length.toString()])} - - - - - - {_("Account")} - {_("Debit")} - {_("Credit")} - - - - {accounts.map((account) => ( - - {account.account} - {formatCurrency(account.debit ?? 0, account.account_currency ?? '')} - {formatCurrency(account.credit ?? 0, account.account_currency ?? '')} - - ))} - -
-
-
- } -} - -const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { - if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") { - return - } - - const invoices = (item.voucher.doc as PaymentEntry).references ?? [] - - const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency - - return
-
- - {(item.voucher.doc as PaymentEntry).party_name} -
- - - -
- - {invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])} -
-
- -
- {invoices.map((invoice) => ( - - - - {_("Document")} - {_("Invoice No")} - {_("Due Date")} - {_("Grand Total")} - {_("Allocated")} - - - - - {invoice.reference_doctype}: {invoice.reference_name} - {invoice.bill_no ?? "-"} - {formatDate(invoice.due_date)} - {formatCurrency(invoice.total_amount, currency ?? '')} - {formatCurrency(invoice.allocated_amount, currency ?? '')} - - -
- ))} -
-
-
- -
-} - -const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { - - const { banks } = useGetBankAccounts() - - const bank = useMemo(() => { - - const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 - - let transferAccount = "" - - if (isWithdrawal) { - transferAccount = (item.voucher.doc as PaymentEntry).paid_to - } else { - transferAccount = (item.voucher.doc as PaymentEntry).paid_from - } - - const transferBankAccount = banks?.find((bank) => bank.account === transferAccount) - - return transferBankAccount - - }, [banks, item]) - - return
- - {bank?.account} -
-} - -const ACTION_TYPE_MAP = { - 'bank_entry': _("Bank Entry"), - 'payment': _("Payment"), - 'transfer': _("Transfer"), - 'match': _("Match"), -} - -const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => { - - const [isOpen, setIsOpen] = useState(false) - - const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry') - const { mutate } = useSWRConfig() - const actionLog = useSetAtom(bankRecActionLog) - const dates = useAtomValue(bankRecDateAtom) - const matchFilters = useAtomValue(bankRecMatchFilters) - const selectedBank = useAtomValue(selectedBankAccountAtom) - - const onUndo = () => { - call({ - bank_transaction_id: item.bankTransaction.name, - voucher_type: item.voucher.reference_doctype, - voucher_id: item.voucher.reference_name, - }).then(() => { - toast.success(type === 'match' ? _("Unmatched") : _("Cancelled")) - - if (selectedBank?.name === item.bankTransaction.bank_account) { - mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`) - mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`) - // Update the matching vouchers for the selected transaction - mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`) - } - - setTimeout(() => { - actionLog((prev) => { - // Find the action and then remove the item from the action. If the action is empty, remove the action from the array - const action = prev.find((action) => action.timestamp === timestamp) - - if (action) { - action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name) - } - // If the action is empty, remove the action from the array - if (action && action.items.length === 0) { - return prev.filter((a) => a.timestamp !== timestamp) - } else { - return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a) - } - }) - }, 100) - - setIsOpen(false) - - }).catch((error) => { - toast.error(_("There was an error while performing the action."), { - duration: 5000, - description: getErrorMessage(error), - }) - }) - } - - return - - - - - - - - {_("Cancel")} - - - - - {type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])} - {type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])} - - {error && } -
- - - - {_("Action Type")} - {ACTION_TYPE_MAP[type]} - - - {_("Voucher Type")} - {_(item.voucher.reference_doctype)} - - - {_("Voucher Name")} - {item.voucher.reference_name} - - - {_("Posting Date")} - {formatDate(item.voucher.posting_date, 'Do MMM YYYY')} - - {type === 'transfer' && item.voucher.doc && - {_("Transfer Account")} - - - - } - {type === 'payment' && item.voucher.doc && - {_("Payment Details")} - - - - } - {type === 'bank_entry' && item.voucher.doc && - {_("Account")} - - } -
-
- - - {_("Close")} - - - -
-
-} - -export default ActionLog \ No newline at end of file +export default ActionLog diff --git a/banking/src/components/features/ActionLog/ActionLogDialog.tsx b/banking/src/components/features/ActionLog/ActionLogDialog.tsx new file mode 100644 index 00000000000..a4eebee9f1d --- /dev/null +++ b/banking/src/components/features/ActionLog/ActionLogDialog.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button' +import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import _ from '@/lib/translate' +import { Loader2Icon } from 'lucide-react' +import { lazy, Suspense } from 'react' + +const ActionLogDialogBody = lazy(() => import('./ActionLogDialogBody')) + +const ActionLogDialogFallback = () => ( +
+ +
+) + +const ActionLogDialog = ({ onClose }: { onClose: () => void }) => { + return ( + + + {_("Reconciliation History")} + {_("View all reconciliation actions taken in this session.")} + + }> + + + + + + + + + ) +} + +export default ActionLogDialog diff --git a/banking/src/components/features/ActionLog/ActionLogDialogBody.tsx b/banking/src/components/features/ActionLog/ActionLogDialogBody.tsx new file mode 100644 index 00000000000..d0ec5fa428e --- /dev/null +++ b/banking/src/components/features/ActionLog/ActionLogDialogBody.tsx @@ -0,0 +1,431 @@ +import { Button } from '@/components/ui/button' +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 { 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 ActionLogDialogBody = () => { + + const actionLog = useAtomValue(bankRecActionLog) + + return
+ {actionLog.map((action) => ( +
+ +
+
+
+ {action.items.map((item, index) => ( + + ))} +
+
+
+
+ ))} + + {actionLog.length === 0 && + + + + + {_("No reconciliation actions found")} + {_("You have not performed any reconciliations in this session yet.")} + + } +
+} + + + +const ActionGroupHeader = ({ action }: { action: ActionLogType }) => { + + const label = useMemo(() => { + switch (action.type) { + case 'match': + return _("Matched") + case 'payment': + if (action.isBulk) { + return _("Bulk Payment") + } + return _("Payment") + + case 'transfer': + if (action.isBulk) { + return _("Bulk Transfer") + } + return _("Transfer") + + case 'bank_entry': + if (action.isBulk) { + return _("Bulk Bank Entry") + } + return _("Bank Entry") + + default: + return _("Action") + } + }, [action]) + + return
+ {action.type === 'match' && } + {action.type === 'payment' && } + {action.type === 'transfer' && } + {action.type === 'bank_entry' && } + + {label} - {dayjs(action.timestamp).fromNow()} + +
+} + +const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => { + + const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + if (item.bankTransaction.bank_account) { + return banks?.find((bank) => bank.name === item.bankTransaction.bank_account) + } + return null + }, [item.bankTransaction.bank_account, banks]) + + const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit + + const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '') + + return
+
+
+
+

{item.bankTransaction.description}

+
+
+ + {item.bankTransaction.bank_account} +
+ +
+ + {formatDate(item.bankTransaction.date, 'Do MMM YYYY')} +
+ +
+
+ {isWithdrawal ? : } + {formatCurrency(amount, currency)} +
+
+
+
+
+
+ + {["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name} + + {item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && } + {item.voucher.reference_doctype === "Journal Entry" && } +
+
+
+
+
+ +
+
+} + +const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { + + return
+ + +
+} + +const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { + + const accounts = useMemo(() => { + + const allAccounts = (item.voucher.doc as JournalEntry).accounts + + return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true) + + }, [item, bank]) + + return <> + {accounts.length === 1 ? {accounts[0].account} : + + + {_("Split across {} accounts", [accounts.length.toString()])} + + + + + + {_("Account")} + {_("Debit")} + {_("Credit")} + + + + {accounts.map((account) => ( + + {account.account} + {formatCurrency(account.debit ?? 0, account.account_currency ?? '')} + {formatCurrency(account.credit ?? 0, account.account_currency ?? '')} + + ))} + +
+
+
+ } +} + +const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { + if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") { + return + } + + const invoices = (item.voucher.doc as PaymentEntry).references ?? [] + + const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency + + return
+
+ + {(item.voucher.doc as PaymentEntry).party_name} +
+ + + +
+ + {invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])} +
+
+ +
+ {invoices.map((invoice) => ( + + + + {_("Document")} + {_("Invoice No")} + {_("Due Date")} + {_("Grand Total")} + {_("Allocated")} + + + + + {invoice.reference_doctype}: {invoice.reference_name} + {invoice.bill_no ?? "-"} + {formatDate(invoice.due_date)} + {formatCurrency(invoice.total_amount, currency ?? '')} + {formatCurrency(invoice.allocated_amount, currency ?? '')} + + +
+ ))} +
+
+
+ +
+} + +const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + + const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 + + let transferAccount = "" + + if (isWithdrawal) { + transferAccount = (item.voucher.doc as PaymentEntry).paid_to + } else { + transferAccount = (item.voucher.doc as PaymentEntry).paid_from + } + + const transferBankAccount = banks?.find((bank) => bank.account === transferAccount) + + return transferBankAccount + + }, [banks, item]) + + return
+ + {bank?.account} +
+} + +const ACTION_TYPE_MAP = { + 'bank_entry': _("Bank Entry"), + 'payment': _("Payment"), + 'transfer': _("Transfer"), + 'match': _("Match"), +} + +const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => { + + const [isOpen, setIsOpen] = useState(false) + + const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry') + const { mutate } = useSWRConfig() + const actionLog = useSetAtom(bankRecActionLog) + const dates = useAtomValue(bankRecDateAtom) + const matchFilters = useAtomValue(bankRecMatchFilters) + const selectedBank = useAtomValue(selectedBankAccountAtom) + + const onUndo = () => { + call({ + bank_transaction_id: item.bankTransaction.name, + voucher_type: item.voucher.reference_doctype, + voucher_id: item.voucher.reference_name, + }).then(() => { + toast.success(type === 'match' ? _("Unmatched") : _("Cancelled")) + + if (selectedBank?.name === item.bankTransaction.bank_account) { + mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`) + // Update the matching vouchers for the selected transaction + mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`) + } + + setTimeout(() => { + actionLog((prev) => { + // Find the action and then remove the item from the action. If the action is empty, remove the action from the array + const action = prev.find((action) => action.timestamp === timestamp) + + if (action) { + action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name) + } + // If the action is empty, remove the action from the array + if (action && action.items.length === 0) { + return prev.filter((a) => a.timestamp !== timestamp) + } else { + return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a) + } + }) + }, 100) + + setIsOpen(false) + + }).catch((error) => { + toast.error(_("There was an error while performing the action."), { + duration: 5000, + description: getErrorMessage(error), + }) + }) + } + + return + + + + + + + + {_("Cancel")} + + + + + {type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])} + {type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])} + + {error && } +
+ + + + {_("Action Type")} + {ACTION_TYPE_MAP[type]} + + + {_("Voucher Type")} + {_(item.voucher.reference_doctype)} + + + {_("Voucher Name")} + {item.voucher.reference_name} + + + {_("Posting Date")} + {formatDate(item.voucher.posting_date, 'Do MMM YYYY')} + + {type === 'transfer' && item.voucher.doc && + {_("Transfer Account")} + + + + } + {type === 'payment' && item.voucher.doc && + {_("Payment Details")} + + + + } + {type === 'bank_entry' && item.voucher.doc && + {_("Account")} + + } +
+
+ + + {_("Close")} + + + +
+
+} + +export default ActionLogDialogBody diff --git a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx index 077ee41ccd2..dd248d31092 100644 --- a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx +++ b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx @@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => { toast.success(_("Copied to clipboard")) }) }, - [copyToClipboard, _], + [copyToClipboard], ) const accountCurrency = useMemo( @@ -200,7 +200,7 @@ const BankClearanceSummaryView = () => { }, }, ], - [_, accountCurrency, bankAccount, companyID, mutate, onCopy], + [accountCurrency, bankAccount, companyID, mutate, onCopy], ) return
diff --git a/banking/src/components/features/BankReconciliation/BankEntryModal.tsx b/banking/src/components/features/BankReconciliation/BankEntryModal.tsx index e6514e5d19c..f0a29455bae 100644 --- a/banking/src/components/features/BankReconciliation/BankEntryModal.tsx +++ b/banking/src/components/features/BankReconciliation/BankEntryModal.tsx @@ -1,831 +1,32 @@ -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 { useAtom } from "jotai" +import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms" +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog" +import { ModalContentFallback } from "@/components/ui/modal-content-fallback" 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" +import { lazy, Suspense } from "react" + +const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent')) const BankEntryModal = () => { + const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom) - const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom) - - return ( - - - - {_("Bank Entry")} - - {_("Record a journal entry for expenses, income or split transactions.")} - - - - - - ) + return ( + + + + {_("Bank Entry")} + + {_("Record a journal entry for expenses, income or split transactions.")} + + + {isOpen && ( + }> + + + )} + + + ) } -const RecordBankEntryModalContent = () => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) - - if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { - return
- {_("No transaction selected")} -
- } - - if (selectedTransaction.length === 1) { - return - } - - return - -} - -const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => { - - const form = useForm<{ - account: string - }>({ - defaultValues: { - account: '' - } - }) - - const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile') - - const onReconcile = useRefreshUnreconciledTransactions() - const addToActionLog = useUpdateActionLog() - - const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) - - const onSubmit = (data: { account: string }) => { - - call({ - bank_transactions: selectedTransactions.map(transaction => transaction.name), - account: data.account - }).then(({ message }) => { - - addToActionLog({ - type: 'bank_entry', - timestamp: (new Date()).getTime(), - isBulk: true, - items: message.map((item) => ({ - bankTransaction: item.transaction, - voucher: { - reference_doctype: "Journal Entry", - reference_name: item.journal_entry.name, - doc: item.journal_entry, - posting_date: item.journal_entry.posting_date, - } - })), - bulkCommonData: { - account: data.account, - } - }) - - toast.success(_("Bank Entries Created"), { - duration: 4000, - }) - - // Set this to the last selected transaction - onReconcile(selectedTransactions[selectedTransactions.length - 1]) - setIsOpen(false) - }) - } - - return
- -
- {error && } - - -
- { - // Do not allow payable and receivable accounts - return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable' - }} - label={_('Account')} - isRequired - /> -
- - - - - - - -
-
- -} - - -interface BankEntryFormData extends Pick { - entries: JournalEntry['accounts'] -} - - -const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const { data: rule } = useGetRuleForTransaction(selectedTransaction) - - const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) - - const onClose = () => { - setIsOpen(false) - } - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const defaultAccounts = useMemo(() => { - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const accounts: Partial[] = [ - { - account: selectedBankAccount?.account ?? '', - bank_account: selectedTransaction.bank_account, - // Bank is debited if it's a deposit - debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, - credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, - party_type: '', - party: '', - cost_center: '' - }] - - // If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse - if (!rule) { - accounts.push( - { - account: '', - // Amounts will be the reverse of the bank account transaction - debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, - credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - } - ) - } else { - // Rule exists, so we need to check the type of rule - if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") { - // Only a single account needs to be added - accounts.push({ - account: rule.account ?? '', - // Amounts will be the reverse of the bank account transaction - debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, - credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - }) - } else { - // For multiple accounts, we need to loop over and add entries for each - // The last row will just be the remaining amount - let hasTotallyEmptyRowEarlier = false; - - let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0 - let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0 - - for (let i = 0; i < (rule.accounts?.length ?? 0); i++) { - - const acc = rule.accounts?.[i] - // If it's the last row, add the difference amount - if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) { - - const differenceAmount = flt(totalDebits - totalCredits, 2) - accounts.push({ - account: acc?.account ?? '', - debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount), - credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - user_remark: acc?.user_remark ?? '', - }) - } else { - - /** - * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" - * So we need to compute the value of the expression - * We can use the eval function to do this. But we need to expose certain variables to the expression. - * One of them is transaction_amount which is the unallocated amount of the selected transaction - * @param expression - The expression to compute - * @returns The computed value - */ - const computeExpression = (expression: string) => { - - const script = ` - const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} - ${expression}; - ` - - let value = 0; - - try { - value = window.eval(script); - } catch (error: unknown) { - console.error(error); - value = 0; - } - - return value; - } - if (!acc?.debit && !acc?.credit) { - hasTotallyEmptyRowEarlier = true; - } - - const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 - const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 - - totalDebits = flt(totalDebits + computedDebit, 2) - totalCredits = flt(totalCredits + computedCredit, 2) - accounts.push({ - account: acc?.account ?? '', - debit: computedDebit, - credit: computedCredit, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - user_remark: acc?.user_remark ?? '', - }) - } - } - } - } - - return accounts - - }, [rule, selectedTransaction, selectedBankAccount]) - - const form = useForm({ - defaultValues: { - voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry', - cheque_date: selectedTransaction.date, - posting_date: selectedTransaction.date, - cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), - user_remark: selectedTransaction.description, - entries: defaultAccounts, - } - }) - - const onReconcile = useRefreshUnreconciledTransactions() - - const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile') - - const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) - const addToActionLog = useUpdateActionLog() - - const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig - - const [isUploading, setIsUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) - - const [files, setFiles] = useState([]) - - const onSubmit = (data: BankEntryFormData) => { - - createBankEntry({ - bank_transaction_name: selectedTransaction.name, - ...data - }).then(async ({ message }) => { - - addToActionLog({ - type: 'bank_entry', - isBulk: false, - timestamp: (new Date()).getTime(), - items: [ - { - bankTransaction: message.transaction, - voucher: { - reference_doctype: "Journal Entry", - reference_name: message.journal_entry.name, - reference_no: message.journal_entry.cheque_no, - reference_date: message.journal_entry.cheque_date, - posting_date: message.journal_entry.posting_date, - doc: message.journal_entry, - } - } - ] - }) - toast.success(_("Bank Entry Created"), { - duration: 4000, - closeButton: true, - action: { - label: _("Undo"), - onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) - }, - actionButtonStyle: { - backgroundColor: "rgb(0, 138, 46)" - } - }) - - if (files.length > 0) { - setIsUploading(true) - - const uploadPromises = files.map(f => { - return frappeFile.uploadFile(f, { - isPrivate: true, - doctype: "Journal Entry", - docname: message.journal_entry.name, - }, (_bytesUploaded, _totalBytes, progress) => { - - setUploadProgress((currentProgress) => { - //If there are multiple files, we need to add the progress to the current progress - return currentProgress + ((progress?.progress ?? 0) / files.length) - }) - - }) - }) - - return Promise.all(uploadPromises).then(() => { - setUploadProgress(0) - setIsUploading(false) - }).catch((error) => { - console.error(error) - toast.error(_("Error uploading attachments"), { - duration: 4000, - }) - setIsUploading(false) - }) - } else { - return Promise.resolve() - } - - }).then(() => { - onReconcile(selectedTransaction) - onClose() - }) - } - - - useHotkeys('meta+s', () => { - form.handleSubmit(onSubmit)() - }, { - enabled: true, - preventDefault: true, - enableOnFormTags: true - }) - - if (isUploading && isCompleted) { - return - } - - return
- -
- {error && } -
- - -
-
- - -
- -
-
- -
- -
-
-
- -
- - -
-
-
- - - - - - - -
-
- - -} - -const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => { - - const { getValues, setValue, control } = useFormContext() - - const { call } = useContext(FrappeContext) as FrappeConfig - - const partyMapRef = useRef>({}) - - const onPartyChange = (value: string, index: number) => { - // Get the account for the party type - if (value) { - if (partyMapRef.current[value]) { - setValue(`entries.${index}.account`, partyMapRef.current[value]) - } else { - call.get('erpnext.accounts.party.get_party_account', { - party: value, - party_type: getValues(`entries.${index}.party_type`), - company: company - }).then((result: { message: string }) => { - setValue(`entries.${index}.account`, result.message) - partyMapRef.current[value] = result.message - }) - } - } else { - setValue(`entries.${index}.account`, '') - } - } - - const { data: accounts } = useGetAccounts() - - const onAccountChange = (value: string, index: number) => { - // If it's an income or expense account, get the default cost center - if (value) { - const account = accounts?.find((acc) => acc.name === value) - if (account && account.report_type === "Profit and Loss") { - // Set the default company cost center - setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '') - return - } - } - - setValue(`entries.${index}.cost_center`, '') - } - - const { fields, append, remove } = useFieldArray({ - control: control, - name: 'entries' - }) - - const onAdd = useCallback(() => { - const existingEntries = getValues('entries') - const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) - const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) - - const remainingAmount = flt(totalDebits - totalCredits, 2) - - // Remaining amount is credit if it's positive - since some debit is pending to be cleared. - const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) - const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 - - append({ - party_type: '', - party: '', - account: '', - debit: debitAmount, - credit: creditAmount, - cost_center: getCompanyCostCenter(company) ?? '' - } as JournalEntryAccount, { - focusName: `entries.${existingEntries.length}.account` - }) - }, [company, append, getValues]) - - const [selectedRows, setSelectedRows] = useState([]) - - const onSelectRow = useCallback((index: number) => { - setSelectedRows(prev => { - if (prev.includes(index)) { - return prev.filter(i => i !== index) - } - return [...prev, index] - }) - }, []) - - const onSelectAll = useCallback(() => { - setSelectedRows(prev => { - if (prev.length === fields.length) { - return [] - } - return [...fields.map((_, index) => index)] - }) - }, [fields]) - - const onRemove = useCallback(() => { - remove(selectedRows) - setSelectedRows([]) - }, [remove, selectedRows]) - - /** - * When add difference is clicked, check if the last row has nothing filled in. - * If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount. - */ - const onAddDifferenceClicked = () => { - - const existingEntries = getValues('entries') - const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) - const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) - - const lastIndex = existingEntries.length - 1 - - const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined) - - const remainingAmount = flt(totalDebits - totalCredits, 2) - - // Remaining amount is credit if it's positive - since some debit is pending to be cleared. - const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) - const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 - - if (isLastRowEmpty) { - setValue(`entries.${lastIndex}.debit`, debitAmount) - setValue(`entries.${lastIndex}.credit`, creditAmount) - } else { - append({ - party_type: '', - party: '', - account: '', - debit: debitAmount, - credit: creditAmount, - cost_center: getCompanyCostCenter(company) ?? '' - } as JournalEntryAccount, { - focusName: `entries.${existingEntries.length}.account` - }) - } - } - - - - return
- - - - 0 && selectedRows.length === fields.length} - onCheckedChange={onSelectAll} /> - {_("Party")} - {_("Account")} - {_("Cost Center")} - {_("Remarks")} - {_("Debit")} - {_("Credit")} - - - - {fields.map((field, index) => ( - - - onSelectRow(index)} - // Make this accessible to screen readers - aria-label={_("Select row {0}", [String(index + 1)])} - disabled={index === 0} - /> - - - -
- - -
- -
- - { - onAccountChange(event.target.value, index) - } - }} - buttonClassName="min-w-64" - readOnly={index === 0} - isRequired - hideLabel - /> - - - - - - - - - - - {_("Bank account debit for deposit")} - : undefined} - /> - - - - - {_("Bank account credit for withdrawal")} - : undefined} - /> - -
- ))} -
-
-
-
-
- -
- {selectedRows.length > 0 &&
- -
} -
- -
-
- -} - -const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => { - - const { control } = useFormContext() - - const party_type = useWatch({ - control, - name: `entries.${index}.party_type` - }) - - if (!party_type) { - return - } - - return { - onChange(event.target.value, index) - }, - }} - hideLabel - readOnly={readOnly} - buttonClassName="rounded-s-none border-s-0 min-w-64" - doctype={party_type} - - /> -} - -const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => { - - const { control } = useFormContext() - - const entries = useWatch({ control, name: 'entries' }) - - const { total, totalCredits, totalDebits } = useMemo(() => { - // Do a total debits - total credits - const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) - const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) - return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits } - }, [entries]) - - const onAddRow = useCallback(() => { - addRow() - }, [addRow]) - - const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { - return {children} - } - - return
-
- {_("Total Debit")} - {formatCurrency(totalDebits, currency)} -
-
- {_("Total Credit")} - {formatCurrency(totalCredits, currency)} -
- {total !== 0 &&
- {_("Difference")} - - - - - - {_("Add a row with the difference amount")} - - -
} - -
- -} - - export default BankEntryModal diff --git a/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx new file mode 100644 index 00000000000..17ef3314a1f --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx @@ -0,0 +1,811 @@ +import { useAtomValue, useSetAtom } from "jotai" +import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { 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 { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress" +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 RecordBankEntryModalContent = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + + if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { + return
+ {_("No transaction selected")} +
+ } + + if (selectedTransaction.length === 1) { + return + } + + return + +} + +const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => { + + const form = useForm<{ + account: string + }>({ + defaultValues: { + account: '' + } + }) + + const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile') + + const onReconcile = useRefreshUnreconciledTransactions() + const addToActionLog = useUpdateActionLog() + + const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + const onSubmit = (data: { account: string }) => { + + call({ + bank_transactions: selectedTransactions.map(transaction => transaction.name), + account: data.account + }).then(({ message }) => { + + addToActionLog({ + type: 'bank_entry', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Journal Entry", + reference_name: item.journal_entry.name, + doc: item.journal_entry, + posting_date: item.journal_entry.posting_date, + } + })), + bulkCommonData: { + account: data.account, + } + }) + + toast.success(_("Bank Entries Created"), { + duration: 4000, + }) + + // Set this to the last selected transaction + onReconcile(selectedTransactions[selectedTransactions.length - 1]) + setIsOpen(false) + }) + } + + return
+ +
+ {error && } + + +
+ { + // Do not allow payable and receivable accounts + return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable' + }} + label={_('Account')} + isRequired + /> +
+ + + + + + + +
+
+ +} + + +interface BankEntryFormData extends Pick { + entries: JournalEntry['accounts'] +} + + +const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const defaultAccounts = useMemo(() => { + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const accounts: Partial[] = [ + { + account: selectedBankAccount?.account ?? '', + bank_account: selectedTransaction.bank_account, + // Bank is debited if it's a deposit + debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + party_type: '', + party: '', + cost_center: '' + }] + + // If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse + if (!rule) { + accounts.push( + { + account: '', + // Amounts will be the reverse of the bank account transaction + debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + } + ) + } else { + // Rule exists, so we need to check the type of rule + if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") { + // Only a single account needs to be added + accounts.push({ + account: rule.account ?? '', + // Amounts will be the reverse of the bank account transaction + debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + }) + } else { + // For multiple accounts, we need to loop over and add entries for each + // The last row will just be the remaining amount + let hasTotallyEmptyRowEarlier = false; + + let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0 + let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0 + + for (let i = 0; i < (rule.accounts?.length ?? 0); i++) { + + const acc = rule.accounts?.[i] + // If it's the last row, add the difference amount + if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) { + + const differenceAmount = flt(totalDebits - totalCredits, 2) + accounts.push({ + account: acc?.account ?? '', + debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount), + credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + user_remark: acc?.user_remark ?? '', + }) + } else { + + /** + * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" + * So we need to compute the value of the expression + * We can use the eval function to do this. But we need to expose certain variables to the expression. + * One of them is transaction_amount which is the unallocated amount of the selected transaction + * @param expression - The expression to compute + * @returns The computed value + */ + const computeExpression = (expression: string) => { + + const script = ` + const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} + ${expression}; + ` + + let value = 0; + + try { + value = window.eval(script); + } catch (error: unknown) { + console.error(error); + value = 0; + } + + return value; + } + if (!acc?.debit && !acc?.credit) { + hasTotallyEmptyRowEarlier = true; + } + + const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 + const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 + + totalDebits = flt(totalDebits + computedDebit, 2) + totalCredits = flt(totalCredits + computedCredit, 2) + accounts.push({ + account: acc?.account ?? '', + debit: computedDebit, + credit: computedCredit, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + user_remark: acc?.user_remark ?? '', + }) + } + } + } + } + + return accounts + + }, [rule, selectedTransaction, selectedBankAccount]) + + const form = useForm({ + defaultValues: { + voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry', + cheque_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + user_remark: selectedTransaction.description, + entries: defaultAccounts, + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress() + + 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) + startTracking(files.length) + + const uploadPromises = files.map((f, fileIndex) => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Journal Entry", + docname: message.journal_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + updateFileProgress(fileIndex, progress?.progress ?? 0) + }) + }) + + return Promise.all(uploadPromises).then(() => { + resetProgress() + setIsUploading(false) + }).catch((error) => { + console.error(error) + toast.error(_("Error uploading attachments"), { + duration: 4000, + }) + resetProgress() + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + + }).then(() => { + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ + +
+
+ + +
+ +
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ + + + + + + +
+
+ + +} + +const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => { + + const { getValues, setValue, control } = useFormContext() + + const { call } = useContext(FrappeContext) as FrappeConfig + + const partyMapRef = useRef>({}) + + const onPartyChange = (value: string, index: number) => { + // Get the account for the party type + if (value) { + if (partyMapRef.current[value]) { + setValue(`entries.${index}.account`, partyMapRef.current[value]) + } else { + call.get('erpnext.accounts.party.get_party_account', { + party: value, + party_type: getValues(`entries.${index}.party_type`), + company: company + }).then((result: { message: string }) => { + setValue(`entries.${index}.account`, result.message) + partyMapRef.current[value] = result.message + }) + } + } else { + setValue(`entries.${index}.account`, '') + } + } + + const { data: accounts } = useGetAccounts() + + const onAccountChange = (value: string, index: number) => { + // If it's an income or expense account, get the default cost center + if (value) { + const account = accounts?.find((acc) => acc.name === value) + if (account && account.report_type === "Profit and Loss") { + // Set the default company cost center + setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '') + return + } + } + + setValue(`entries.${index}.cost_center`, '') + } + + const { fields, append, remove } = useFieldArray({ + control: control, + name: 'entries' + }) + + const onAdd = useCallback(() => { + const existingEntries = getValues('entries') + const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + + const remainingAmount = flt(totalDebits - totalCredits, 2) + + // Remaining amount is credit if it's positive - since some debit is pending to be cleared. + const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) + const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 + + append({ + party_type: '', + party: '', + account: '', + debit: debitAmount, + credit: creditAmount, + cost_center: getCompanyCostCenter(company) ?? '' + } as JournalEntryAccount, { + focusName: `entries.${existingEntries.length}.account` + }) + }, [company, append, getValues]) + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onRemove = useCallback(() => { + // Do not remove the first row + remove(selectedRows.filter(index => index !== 0)) + setSelectedRows([]) + }, [remove, selectedRows]) + + /** + * When add difference is clicked, check if the last row has nothing filled in. + * If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount. + */ + const onAddDifferenceClicked = () => { + + const existingEntries = getValues('entries') + const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + + const lastIndex = existingEntries.length - 1 + + const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined) + + const remainingAmount = flt(totalDebits - totalCredits, 2) + + // Remaining amount is credit if it's positive - since some debit is pending to be cleared. + const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) + const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 + + if (isLastRowEmpty) { + setValue(`entries.${lastIndex}.debit`, debitAmount) + setValue(`entries.${lastIndex}.credit`, creditAmount) + } else { + append({ + party_type: '', + party: '', + account: '', + debit: debitAmount, + credit: creditAmount, + cost_center: getCompanyCostCenter(company) ?? '' + } as JournalEntryAccount, { + focusName: `entries.${existingEntries.length}.account` + }) + } + } + + + + return
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Party")} + {_("Account")} + {_("Cost Center")} + {_("Remarks")} + {_("Debit")} + {_("Credit")} + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + disabled={index === 0} + /> + + + +
+ + +
+ +
+ + { + onAccountChange(event.target.value, index) + } + }} + buttonClassName="min-w-64" + readOnly={index === 0} + isRequired + hideLabel + /> + + + + + + + + + + + {_("Bank account debit for deposit")} + : undefined} + /> + + + + + {_("Bank account credit for withdrawal")} + : undefined} + /> + +
+ ))} +
+
+
+
+
+ +
+ {selectedRows.length > 0 &&
+ +
} +
+ +
+
+ +} + +const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => { + + const { control } = useFormContext() + + const party_type = useWatch({ + control, + name: `entries.${index}.party_type` + }) + + if (!party_type) { + return + } + + return { + onChange(event.target.value, index) + }, + }} + hideLabel + readOnly={readOnly} + buttonClassName="rounded-s-none border-s-0 min-w-64" + doctype={party_type} + + /> +} + +const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => { + + const { control } = useFormContext() + + const entries = useWatch({ control, name: 'entries' }) + + const { total, totalCredits, totalDebits } = useMemo(() => { + // Do a total debits - total credits + const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits } + }, [entries]) + + const onAddRow = useCallback(() => { + addRow() + }, [addRow]) + + const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { + return {children} + } + + return
+
+ {_("Total Debit")} + {formatCurrency(totalDebits, currency)} +
+
+ {_("Total Credit")} + {formatCurrency(totalCredits, currency)} +
+ {total !== 0 &&
+ {_("Difference")} + + + + + + {_("Add a row with the difference amount")} + + +
} + +
+ +} + + + +export default RecordBankEntryModalContent diff --git a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx index acfed95aa15..7b505efadc3 100644 --- a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx +++ b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx @@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => { toast.success(_("Copied to clipboard")) }) }, - [copyToClipboard, _], + [copyToClipboard], ) const statementColumns = useMemo[]>( @@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => { cell: ({ row }) => formatDate(row.original.clearance_date), }, ], - [_, onCopy], + [onCopy], ) const statementRows = useMemo(() => { diff --git a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx index c37165349fe..17f231a0833 100644 --- a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx +++ b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx @@ -176,7 +176,7 @@ const BankTransactionListView = () => { ), }, ], - [_, accountCurrency, onUndo], + [accountCurrency, onUndo], ) const [search, setSearch] = useDebounceValue('', 250) diff --git a/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx index a9996a2ddef..3f43c987dbf 100644 --- a/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx +++ b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx @@ -1,125 +1,52 @@ -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 { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { useAtom } from "jotai" +import { Loader2Icon } from "lucide-react" +import { lazy, Suspense } from "react" +import { bankRecUnreconcileModalAtom } from "./bankRecAtoms" import _ from "@/lib/translate" +const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody')) + +const BankTransactionUnreconcileModalFallback = () => ( +
+ +
+) + const BankTransactionUnreconcileModal = () => { + const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) - 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 onOpenChange = (v: boolean) => { + if (!v) { + setBankRecUnreconcileModal('') + } + } + if (!unreconcileModal) { + return null + } + return ( + + + + {_("Undo Transaction Reconciliation")} + + {_("Are you sure you want to unreconcile this transaction?")} + + + }> + + + + + ) } -const BankTransactionUnreconcileModalContent = () => { - const bankAccount = useAtomValue(selectedBankAccountAtom) - const dates = useAtomValue(bankRecDateAtom) - - const { mutate } = useSWRConfig() - - const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) - - const { data: transaction, error } = useFrappeGetDoc('Bank Transaction', unreconcileModal) - - const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction') - - const onUnreconcile = (event: React.MouseEvent) => { - call({ - transaction_name: unreconcileModal - }).then(() => { - // Mutate the transactions list, unreconciled transactions list and account closing balance - mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) - mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) - mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`) - toast.success(_("Transaction Unreconciled")) - setBankRecUnreconcileModal('') - }) - - event.preventDefault() - } - - const vouchersWhichWillBeCancelled = useMemo(() => { - return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created') - }, [transaction]) - - return
-
- {error && } - {unreconcileError && } - {transaction && } - {_("This transaction has been reconciled with the following document(s):")} - - - - {_("Document")} - {_("Amount")} - {_("Reconciliation Type")} - - - - {transaction?.payment_entries?.map((voucher) => { - return - - - {`${_(voucher.payment_document)}: ${voucher.payment_entry}`} - - - {formatCurrency(voucher.allocated_amount)} - {voucher.reconciliation_type === 'Voucher Created' ? - {_(voucher.reconciliation_type)} : - {_(voucher.reconciliation_type ?? "Matched")}} - - })} - -
-
- {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && The following documents will be cancelled:} - {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 &&
    - {vouchersWhichWillBeCancelled?.map((voucher) => { - return
  1. {_(voucher.payment_document)}: {voucher.payment_entry}
  2. - })} -
} -
-
- - {_("Cancel")} - - {_("Unreconcile")} - - -
-} - -export default BankTransactionUnreconcileModal \ No newline at end of file +export default BankTransactionUnreconcileModal diff --git a/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModalBody.tsx b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModalBody.tsx new file mode 100644 index 00000000000..6cb9da1e36e --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModalBody.tsx @@ -0,0 +1,109 @@ +import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } 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 BankTransactionUnreconcileModalBody = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + const { mutate } = useSWRConfig() + + const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) + + const { data: transaction, error, isLoading } = 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(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`) + toast.success(_("Transaction Unreconciled")) + setBankRecUnreconcileModal('') + }) + + event.preventDefault() + } + + const vouchersWhichWillBeCancelled = useMemo(() => { + return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created') + }, [transaction]) + + return ( + <> +
+ {error && } + {unreconcileError && } + {transaction && } + {_("This transaction has been reconciled with the following document(s):")} + + + + {_("Document")} + {_("Amount")} + {_("Reconciliation Type")} + + + + {transaction?.payment_entries?.map((voucher) => { + return ( + + + + {`${_(voucher.payment_document)}: ${voucher.payment_entry}`} + + + {formatCurrency(voucher.allocated_amount)} + + {voucher.reconciliation_type === 'Voucher Created' ? + {_(voucher.reconciliation_type)} : + {_(voucher.reconciliation_type ?? "Matched")}} + + + ) + })} + +
+
+ {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && ( + The following documents will be cancelled: + )} + {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && ( +
    + {vouchersWhichWillBeCancelled?.map((voucher) => { + return
  1. {_(voucher.payment_document)}: {voucher.payment_entry}
  2. + })} +
+ )} +
+
+ + {_("Cancel")} + + {_("Unreconcile")} + + + + ) +} + +export default BankTransactionUnreconcileModalBody diff --git a/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx b/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx index 58940aa6f91..aba419f1445 100644 --- a/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx +++ b/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx @@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => { }) }) }, - [clearClearingDate, mutate, _], + [clearClearingDate, mutate], ) const accountCurrency = useMemo( @@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => { ), }, ], - [_, accountCurrency, onClearClick], + [accountCurrency, onClearClick], ) return
diff --git a/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx b/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx index 32eeb672fcd..7549cf74150 100644 --- a/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx +++ b/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx @@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte import { Button } from "@/components/ui/button" import CurrencyInput from 'react-currency-input-field' import { getCurrencySymbol } from "@/lib/currency" -import { Virtuoso } from 'react-virtuoso' +import { useVirtualizer } from '@tanstack/react-virtual' import { formatDate } from "@/lib/date" import { Badge } from "@/components/ui/badge" import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers" @@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp import { Skeleton } from "@/components/ui/skeleton" import { slug } from "@/lib/frappe" import _ from "@/lib/translate" +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 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" @@ -69,6 +69,59 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => { } +/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */ +function VirtualizedListBody({ + items, + height, + getItemKey, + children, + estimateSize = 74, +}: { + items: T[] + height: number + getItemKey: (item: T, index: number) => string | number + children: (item: T, index: number) => React.ReactNode + estimateSize?: number +}) { + const scrollRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => estimateSize, + overscan: 8, + getItemKey: (index) => String(getItemKey(items[index], index)), + }) + + if (items.length === 0) { + return null + } + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => ( +
+ {children(items[virtualRow.index], virtualRow.index)} +
+ ))} +
+
+ ) +} const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => { const bankAccount = useAtomValue(selectedBankAccountAtom) @@ -134,6 +187,7 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) } const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0 + const listHeight = contentHeight - 72 if (isLoading) { return @@ -222,14 +276,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")} description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />} - ( - - )} - style={{ minHeight: Math.max(contentHeight - 80, 400) }} - totalCount={results?.length} - /> + transaction.name} + > + {(transaction) => } +
} @@ -559,11 +613,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom) const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) - if (!rule) { - return null - } - const getActionIcon = () => { + if (!rule) return null switch (rule.classify_as) { case "Bank Entry": return @@ -577,6 +628,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } const getActionStyles = () => { + if (!rule) return {} switch (rule.classify_as) { case "Bank Entry": return { @@ -610,6 +662,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } const handleActionClick = () => { + if (!rule) return switch (rule.classify_as) { case "Bank Entry": setRecordJournalEntryModalOpen(true) @@ -624,6 +677,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } const getActionDescription = () => { + if (!rule) return "" switch (rule.classify_as) { case "Bank Entry": return _("Create a journal entry for expenses, income or split transactions") @@ -636,8 +690,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } } - useHotkeys('meta+r', () => { - // + useHotkeys('alt+r', () => { handleActionClick() }, { enabled: true, @@ -647,6 +700,10 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = const styles = getActionStyles() + if (!rule) { + return null + } + return ( @@ -721,6 +778,9 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction) + const voucherList = vouchers?.message ?? [] + const listHeight = contentHeight - 120 + if (error) { return } @@ -747,7 +807,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U or
- {vouchers?.message.length === 0 && + {voucherList.length === 0 && @@ -756,14 +816,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U {_("No vouchers found for this transaction")} } - ( - - )} - style={{ height: contentHeight }} - totalCount={vouchers?.message.length} - /> + voucher.name} + > + {(voucher, index) => } + } diff --git a/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx b/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx index cebb82cd640..01bfee059d7 100644 --- a/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx +++ b/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx @@ -1,1301 +1,32 @@ -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 { useAtom } from "jotai" +import { bankRecRecordPaymentModalAtom } from "./bankRecAtoms" +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog" +import { ModalContentFallback } from "@/components/ui/modal-content-fallback" 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" +import { lazy, Suspense } from "react" + +const RecordPaymentModalContent = lazy(() => import('./RecordPaymentModalContent')) const RecordPaymentModal = () => { + const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom) - const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom) - - return ( - - - - {_("Record Payment")} - - {_("Record a payment entry against a customer or supplier")} - - - - - - ) + return ( + + + + {_("Record Payment")} + + {_("Record a payment entry against a customer or supplier")} + + + {isOpen && ( + }> + + + )} + + + ) } - -const RecordPaymentModalContent = () => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) - - if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { - return
- {_("No transaction selected")} -
- } - - if (selectedTransaction.length === 1) { - return - } - - return - -} - -const BulkPaymentEntryForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { - - - const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) - - const form = useForm<{ - party_type: PaymentEntry['party_type'], - party: PaymentEntry['party'], - party_name: PaymentEntry['party_name'], - /** GL account that's paid from or paid to */ - account: string - mode_of_payment: PaymentEntry['mode_of_payment'] - }>() - - const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_payment_entry_and_reconcile') - - const onReconcile = useRefreshUnreconciledTransactions() - - const addToActionLog = useUpdateActionLog() - - const onSubmit = (data: { party_type: PaymentEntry['party_type'], party: PaymentEntry['party'], account: string, mode_of_payment: PaymentEntry['mode_of_payment'] }) => { - - createPaymentEntry({ - bank_transaction_names: transactions.map((transaction) => transaction.name), - party_type: data.party_type, - party: data.party, - account: data.account - }).then(({ message }) => { - - addToActionLog({ - type: 'payment', - timestamp: (new Date()).getTime(), - isBulk: true, - items: message.map((item) => ({ - bankTransaction: item.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: item.payment_entry.name, - reference_no: item.payment_entry.reference_no, - reference_date: item.payment_entry.reference_date, - posting_date: item.payment_entry.posting_date, - party_type: item.payment_entry.party_type, - party: item.payment_entry.party, - doc: item.payment_entry, - } - })), - bulkCommonData: { - party_type: data.party_type, - party: data.party, - account: data.account, - } - }) - - toast.success(_("Payment Recorded"), { - duration: 4000, - closeButton: true, - }) - onReconcile(transactions[transactions.length - 1]) - setIsOpen(false) - }) - } - - const party_type = useWatch({ control: form.control, name: 'party_type' }) - - const party_name = useWatch({ control: form.control, name: 'party_name' }) - - const party = useWatch({ control: form.control, name: 'party' }) - - const { call } = useContext(FrappeContext) as FrappeConfig - - const currentCompany = useCurrentCompany() - - const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') - - const onPartyChange = (event: ChangeEvent) => { - // Fetch the party and account - if (event.target.value) { - call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { - company: company, - party_type: party_type, - party: event.target.value, - date: today() - }).then((res) => { - form.setValue('party_name', res.message.party_name) - form.setValue('account', res.message.party_account) - }) - } else { - // Clear the party and account - form.setValue('party_name', '') - form.setValue('account', '') - } - - } - - return
- -
- - {error && } - - - -
-
- -
-
- {party_type ? : - } - - -
- -
- { - if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { - return acc.account_type === 'Payable' - } else if (party_type === 'Customer') { - return acc.account_type === 'Receivable' - } - return true - }} - /> -
- -
- -
- -
- - - - - - - - -
-
- - -} - -const PaymentEntryForm = ({ selectedTransaction, selectedBankAccount }: { selectedTransaction: UnreconciledTransaction, selectedBankAccount: SelectedBank }) => { - - const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) - - const onClose = () => { - setIsOpen(false) - } - - const { data: rule } = useGetRuleForTransaction(selectedTransaction) - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const form = useForm({ - defaultValues: { - payment_type: isWithdrawal ? 'Pay' : 'Receive', - bank_account: selectedTransaction.bank_account, - company: selectedTransaction?.company, - // If the money is paid, it's usually to a supplier. If it's received, it's usually from a customer - party_type: rule?.party_type ?? (isWithdrawal ? 'Supplier' : 'Customer'), - party: rule?.party ?? '', - // If the transaction is a withdrawal, set the paid from to the selected bank account - paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // If the transaction is a deposit, set the paid to to the selected bank account - paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // Set the amount to the amount of the selected transaction - paid_amount: selectedTransaction.unallocated_amount, - base_paid_amount: selectedTransaction.unallocated_amount, - received_amount: selectedTransaction.unallocated_amount, - base_received_amount: selectedTransaction.unallocated_amount, - reference_date: selectedTransaction.date, - posting_date: selectedTransaction.date, - reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), - target_exchange_rate: 1, - source_exchange_rate: 1, - } - }) - - const onReconcile = useRefreshUnreconciledTransactions() - - const setUnpaidInvoiceOpen = useSetAtom(isUnpaidInvoicesButtonOpen) - - useEffect(() => { - if (rule && rule.party && rule.party_type && rule.account) { - setUnpaidInvoiceOpen(true) - } - - }, [rule, setUnpaidInvoiceOpen]) - - const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_and_reconcile') - - const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) - - const addToActionLog = useUpdateActionLog() - - const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig - - const [isUploading, setIsUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) - - const [files, setFiles] = useState([]) - - const onSubmit = (data: PaymentEntry) => { - - createPaymentEntry({ - bank_transaction_name: selectedTransaction.name, - payment_entry_doc: { - ...data, - custom_remarks: data.remarks ? true : false - } - }).then(async ({ message }) => { - addToActionLog({ - type: 'payment', - timestamp: (new Date()).getTime(), - isBulk: false, - items: [ - { - bankTransaction: message.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: message.payment_entry.name, - reference_no: message.payment_entry.reference_no, - reference_date: message.payment_entry.reference_date, - posting_date: message.payment_entry.posting_date, - doc: message.payment_entry, - } - } - ] - }) - toast.success(_("Payment Entry Created"), { - duration: 4000, - closeButton: true, - action: { - label: _("Undo"), - onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) - }, - actionButtonStyle: { - backgroundColor: "rgb(0, 138, 46)" - } - }) - - if (files.length > 0) { - setIsUploading(true) - - const uploadPromises = files.map(f => { - return frappeFile.uploadFile(f, { - isPrivate: true, - doctype: "Payment Entry", - docname: message.payment_entry.name, - }, (_bytesUploaded, _totalBytes, progress) => { - - setUploadProgress((currentProgress) => { - //If there are multiple files, we need to add the progress to the current progress - return currentProgress + ((progress?.progress ?? 0) / files.length) - }) - - }) - }) - - return Promise.all(uploadPromises).then(() => { - setUploadProgress(0) - setIsUploading(false) - }) - } else { - return Promise.resolve() - } - - }).then(() => { - setUploadProgress(0) - setIsUploading(false) - onReconcile(selectedTransaction) - onClose() - }) - } - - - useHotkeys('meta+s', () => { - form.handleSubmit(onSubmit)() - }, { - enabled: true, - preventDefault: true, - enableOnFormTags: true - }) - - if (isUploading && isCompleted) { - return - } - - return
- -
- {error && } -
- -
-

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

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

{_("Invoices")}

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

Other Charges / Deductions

- -
- - - - 0 && selectedRows.length === fields.length} - onCheckedChange={onSelectAll} /> - {_("Account")} * - {_("Cost Center")} * - {_("Description")} - {_("Amount")} * - - - - {fields.map((field, index) => ( - - - onSelectRow(index)} - // Make this accessible to screen readers - aria-label={_("Select row {0}", [String(index + 1)])} - /> - - - - - - - - - - - - - { - setTotalAllocatedAmount() - } - }} - /> - - - ))} - -
-
-
-
- -
- {selectedRows.length > 0 &&
- -
} -
-
-
-} - -const TotalDeductions = ({ currency }: { currency: string }) => { - - const { control } = useFormContext() - - const total_deductions = useWatch({ control, name: 'deductions' })?.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0) ?? 0 - - return ({formatCurrency(total_deductions, currency)}) -} -export default RecordPaymentModal \ No newline at end of file +export default RecordPaymentModal diff --git a/banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx b/banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx new file mode 100644 index 00000000000..bb42e85b17b --- /dev/null +++ b/banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx @@ -0,0 +1,1279 @@ +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 { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress" +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 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, + mode_of_payment: data.mode_of_payment + }).then(({ message }) => { + + addToActionLog({ + type: 'payment', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: item.payment_entry.name, + reference_no: item.payment_entry.reference_no, + reference_date: item.payment_entry.reference_date, + posting_date: item.payment_entry.posting_date, + party_type: item.payment_entry.party_type, + party: item.payment_entry.party, + doc: item.payment_entry, + } + })), + bulkCommonData: { + party_type: data.party_type, + party: data.party, + account: data.account, + } + }) + + toast.success(_("Payment Recorded"), { + duration: 4000, + closeButton: true, + }) + onReconcile(transactions[transactions.length - 1]) + setIsOpen(false) + }) + } + + const party_type = useWatch({ control: form.control, name: 'party_type' }) + + const party_name = useWatch({ control: form.control, name: 'party_name' }) + + const party = useWatch({ control: form.control, name: 'party' }) + + const { call } = useContext(FrappeContext) as FrappeConfig + + const currentCompany = useCurrentCompany() + + const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') + + const onPartyChange = (event: ChangeEvent) => { + // Fetch the party and account + if (event.target.value) { + call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { + company: company, + party_type: party_type, + party: event.target.value, + date: today() + }).then((res) => { + form.setValue('party_name', res.message.party_name) + form.setValue('account', res.message.party_account) + }) + } else { + // Clear the party and account + form.setValue('party_name', '') + form.setValue('account', '') + } + + } + + return
+ +
+ + {error && } + + + +
+
+ +
+
+ {party_type ? : + } + + +
+ +
+ { + if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { + return acc.account_type === 'Payable' + } else if (party_type === 'Customer') { + return acc.account_type === 'Receivable' + } + return true + }} + /> +
+ +
+ +
+ +
+ + + + + + + + +
+
+ + +} + +const PaymentEntryForm = ({ selectedTransaction, selectedBankAccount }: { selectedTransaction: UnreconciledTransaction, selectedBankAccount: SelectedBank }) => { + + const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const form = useForm({ + defaultValues: { + payment_type: isWithdrawal ? 'Pay' : 'Receive', + bank_account: selectedTransaction.bank_account, + company: selectedTransaction?.company, + // If the money is paid, it's usually to a supplier. If it's received, it's usually from a customer + party_type: rule?.party_type ?? (isWithdrawal ? 'Supplier' : 'Customer'), + party: rule?.party ?? '', + // If the transaction is a withdrawal, set the paid from to the selected bank account + paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // If the transaction is a deposit, set the paid to to the selected bank account + paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // Set the amount to the amount of the selected transaction + paid_amount: selectedTransaction.unallocated_amount, + base_paid_amount: selectedTransaction.unallocated_amount, + received_amount: selectedTransaction.unallocated_amount, + base_received_amount: selectedTransaction.unallocated_amount, + reference_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + target_exchange_rate: 1, + source_exchange_rate: 1, + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const setUnpaidInvoiceOpen = useSetAtom(isUnpaidInvoicesButtonOpen) + + useEffect(() => { + if (rule && rule.party && rule.party_type && rule.account) { + setUnpaidInvoiceOpen(true) + } + + }, [rule, setUnpaidInvoiceOpen]) + + const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_and_reconcile') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress() + + 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) + startTracking(files.length) + + const uploadPromises = files.map((f, fileIndex) => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Payment Entry", + docname: message.payment_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + updateFileProgress(fileIndex, progress?.progress ?? 0) + }) + }) + + return Promise.all(uploadPromises).then(() => { + resetProgress() + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + + }).then(() => { + resetProgress() + setIsUploading(false) + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ +
+

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

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

{_("Invoices")}

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

Other Charges / Deductions

+ +
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Account")} * + {_("Cost Center")} * + {_("Description")} + {_("Amount")} * + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + /> + + + + + + + + + + + + + { + setTotalAllocatedAmount() + } + }} + /> + + + ))} + +
+
+
+
+ +
+ {selectedRows.length > 0 &&
+ +
} +
+
+
+} + +const TotalDeductions = ({ currency }: { currency: string }) => { + + const { control } = useFormContext() + + const total_deductions = useWatch({ control, name: 'deductions' })?.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0) ?? 0 + + return ({formatCurrency(total_deductions, currency)}) +} + +export default RecordPaymentModalContent diff --git a/banking/src/components/features/BankReconciliation/TransferModal.tsx b/banking/src/components/features/BankReconciliation/TransferModal.tsx index fb824dbc6f7..7411abb3df6 100644 --- a/banking/src/components/features/BankReconciliation/TransferModal.tsx +++ b/banking/src/components/features/BankReconciliation/TransferModal.tsx @@ -1,555 +1,32 @@ -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 { useAtom } from 'jotai' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { ModalContentFallback } from '@/components/ui/modal-content-fallback' 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' +import { lazy, Suspense } from 'react' +import { bankRecTransferModalAtom } from './bankRecAtoms' + +const TransferModalContent = lazy(() => import('./TransferModalContent')) const TransferModal = () => { + const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom) - const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom) - - return ( - - - - {_("Transfer")} - - {_("Record an internal transfer to another bank/credit card/cash account.")} - - - - - - ) + return ( + + + + {_("Transfer")} + + {_("Record an internal transfer to another bank/credit card/cash account.")} + + + {isOpen && ( + }> + + + )} + + + ) } -const TransferModalContent = () => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) - - if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { - return
- {_("No transaction selected")} -
- } - - if (selectedTransaction.length === 1) { - return - } - - return - -} - -const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { - - const form = useForm<{ - bank_account: string - }>() - - const setIsOpen = useSetAtom(bankRecTransferModalAtom) - - const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer') - - const onReconcile = useRefreshUnreconciledTransactions() - const addToActionLog = useUpdateActionLog() - - const onSubmit = (data: { bank_account: string }) => { - - createPaymentEntry({ - bank_transaction_names: transactions.map((transaction) => transaction.name), - bank_account: data.bank_account - }).then(({ message }) => { - addToActionLog({ - type: 'transfer', - timestamp: (new Date()).getTime(), - isBulk: true, - items: message.map((item) => ({ - bankTransaction: item.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: item.payment_entry.name, - posting_date: item.payment_entry.posting_date, - doc: item.payment_entry, - } - })), - bulkCommonData: { - bank_account: data.bank_account, - } - }) - toast.success(_("Transfer Recorded"), { - duration: 4000, - closeButton: true, - }) - onReconcile(transactions[transactions.length - 1]) - setIsOpen(false) - }) - - } - - const onAccountChange = (account: string) => { - form.setValue('bank_account', account) - } - - const selectedAccount = useWatch({ control: form.control, name: 'bank_account' }) - - const currentCompany = useCurrentCompany() - - const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') - - console.log("This is here", transactions) - - return
- -
- - {error && } - - - - - - - - - - - -
-
- - -} - -interface InternalTransferFormFields extends PaymentEntry { - mirror_transaction_name?: string -} - -const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => { - - - const setIsOpen = useSetAtom(bankRecTransferModalAtom) - - const onClose = () => { - setIsOpen(false) - } - - const { data: rule } = useGetRuleForTransaction(selectedTransaction) - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const form = useForm({ - defaultValues: { - payment_type: 'Internal Transfer', - company: selectedTransaction?.company, - // If the transaction is a withdrawal, set the paid from to the selected bank account - paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // If the transaction is a deposit, set the paid to to the selected bank account - paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // Set the amount to the amount of the selected transaction - paid_amount: selectedTransaction.unallocated_amount, - received_amount: selectedTransaction.unallocated_amount, - reference_date: selectedTransaction.date, - posting_date: selectedTransaction.date, - reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), - } - }) - - const onReconcile = useRefreshUnreconciledTransactions() - - const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer') - - const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) - const addToActionLog = useUpdateActionLog() - - const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig - - const [isUploading, setIsUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) - - const [files, setFiles] = useState([]) - - const onSubmit = (data: InternalTransferFormFields) => { - - createPaymentEntry({ - bank_transaction_name: selectedTransaction.name, - ...data, - custom_remarks: data.remarks ? true : false, - // Pass this to reconcile both at the same time - mirror_transaction_name: data.mirror_transaction_name - }).then(async ({ message }) => { - addToActionLog({ - type: 'transfer', - timestamp: (new Date()).getTime(), - isBulk: false, - items: [ - { - bankTransaction: message.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: message.payment_entry.name, - reference_no: message.payment_entry.reference_no, - reference_date: message.payment_entry.reference_date, - posting_date: message.payment_entry.posting_date, - doc: message.payment_entry, - } - } - ] - }) - toast.success(_("Transfer Recorded"), { - duration: 4000, - closeButton: true, - action: { - label: _("Undo"), - onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) - }, - actionButtonStyle: { - backgroundColor: "rgb(0, 138, 46)" - } - }) - - if (files.length > 0) { - setIsUploading(true) - - const uploadPromises = files.map(f => { - return frappeFile.uploadFile(f, { - isPrivate: true, - doctype: "Payment Entry", - docname: message.payment_entry.name, - }, (_bytesUploaded, _totalBytes, progress) => { - - setUploadProgress((currentProgress) => { - //If there are multiple files, we need to add the progress to the current progress - return currentProgress + ((progress?.progress ?? 0) / files.length) - }) - - }) - }) - - return Promise.all(uploadPromises).then(() => { - setUploadProgress(0) - setIsUploading(false) - }) - } else { - return Promise.resolve() - } - }).then(() => { - setUploadProgress(0) - setIsUploading(false) - onReconcile(selectedTransaction) - onClose() - }) - } - - - useHotkeys('meta+s', () => { - form.handleSubmit(onSubmit)() - }, { - enabled: true, - preventDefault: true, - enableOnFormTags: true - }) - - const onAccountChange = (account: string, is_mirror: boolean = false) => { - //If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into - if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) { - form.setValue('paid_to', account) - } else { - form.setValue('paid_from', account) - } - - if (!is_mirror) { - // Reset the mirror transaction name - form.setValue('mirror_transaction_name', '') - } - } - - const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' }) - - const direction = useDirection() - - if (isUploading && isCompleted) { - return - } - - return
- -
- {error && } -
- - -
-
- - -
- -
-
- -
-

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

- - -
-
-
-
- account.name !== selectedBankAccount.account} - isRequired - /> -
- -
- {direction === 'ltr' ? : } -
-
- account.name !== selectedBankAccount.account} - /> -
-
-
- -
-
- - - -
- - -
-
-
- - - - - - -
-
- -} - - -const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company: string }) => { - - const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount) - - return
- {banks.map((bank) => ( -
onAccountChange(bank.account ?? '')} - > - -
- {bank.account_name} {bank.bank_account_no && ({bank.bank_account_no})} - {bank.account} -
-
- ))} - -
- -} - -const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => { - - const { data } = useFrappeGetCall('frappe.client.get_value', { - doctype: 'Company', - filters: company, - fieldname: 'default_cash_account' - }, undefined, { - revalidateOnFocus: false, - revalidateIfStale: false, - }) - - const account = data?.message?.default_cash_account - - if (account) { - return
setSelectedAccount(account ?? '')} - > -
- -
-
- Cash - {data?.message?.default_cash_account} -
-
- } - - return null -} - - -const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => { - - const { setValue, watch } = useFormContext() - - const mirrorTransactionName = watch('mirror_transaction_name') - const paid_from = watch('paid_from') - const paid_to = watch('paid_to') - - const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', { - transaction_id: transaction.name - }, undefined, { - revalidateOnFocus: false, - revalidateIfStale: false, - }) - - // Get bank accounts to find the logo - const { banks } = useGetBankAccounts() - - const bank = useMemo(() => { - if (data?.message?.bank_account && banks) { - return banks.find(bank => bank.name === data.message.bank_account) - } - return null - }, [data?.message?.bank_account, banks]) - - const selectTransaction = () => { - if (data?.message) { - setValue('mirror_transaction_name', data.message.name) - onAccountChange(data.message.account, true) - } - } - - if (data?.message) { - - const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0 - - const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit - const currency = data.message.currency - - const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account - - const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected - - return (
-
-
-
-
- - {_("Suggested Transfer to {0}", [data.message.account])} -
-
- {_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])} - {_("Accepting the suggestion will reconcile both transactions.")} -
- -
-
- - {formatDate(data.message.date, 'Do MMM YYYY')} -
- {data.message.description} -
-
-
-
-
- -
-
-
- {isWithdrawal ? : } - {isWithdrawal ? _('Transferred Out') : _('Received')} -
-
- {formatCurrency(amount, currency)} -
- -
-
-
-
- ) - } - - return null -} - -export default TransferModal \ No newline at end of file +export default TransferModal diff --git a/banking/src/components/features/BankReconciliation/TransferModalContent.tsx b/banking/src/components/features/BankReconciliation/TransferModalContent.tsx new file mode 100644 index 00000000000..d24cafebe40 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/TransferModalContent.tsx @@ -0,0 +1,530 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms' +import { DialogFooter, DialogClose } 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 { useMultiFileUploadProgress } from '@/hooks/useMultiFileUploadProgress' +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 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 ?? '') + + return
+ +
+ + {error && } + + + + + + + + + + + +
+
+ + +} + +interface InternalTransferFormFields extends PaymentEntry { + mirror_transaction_name?: string +} + +const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => { + + + const setIsOpen = useSetAtom(bankRecTransferModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const form = useForm({ + defaultValues: { + payment_type: 'Internal Transfer', + company: selectedTransaction?.company, + // If the transaction is a withdrawal, set the paid from to the selected bank account + paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // If the transaction is a deposit, set the paid to to the selected bank account + paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // Set the amount to the amount of the selected transaction + paid_amount: selectedTransaction.unallocated_amount, + received_amount: selectedTransaction.unallocated_amount, + reference_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress() + + 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) + startTracking(files.length) + + const uploadPromises = files.map((f, fileIndex) => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Payment Entry", + docname: message.payment_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + updateFileProgress(fileIndex, progress?.progress ?? 0) + }) + }) + + return Promise.all(uploadPromises).then(() => { + resetProgress() + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + }).then(() => { + resetProgress() + setIsUploading(false) + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + const onAccountChange = (account: string, is_mirror: boolean = false) => { + //If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into + if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) { + form.setValue('paid_to', account) + } else { + form.setValue('paid_from', account) + } + + if (!is_mirror) { + // Reset the mirror transaction name + form.setValue('mirror_transaction_name', '') + } + } + + const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' }) + + const direction = useDirection() + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ + +
+
+ + +
+ +
+
+ +
+

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

+ + +
+
+
+
+ account.name !== selectedBankAccount.account} + isRequired + /> +
+ +
+ {direction === 'ltr' ? : } +
+
+ account.name !== selectedBankAccount.account} + /> +
+
+
+ +
+
+ + + +
+ + +
+
+
+ + + + + + +
+
+ +} + + +const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company?: string }) => { + + const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount) + + return
+ {banks.map((bank) => ( + + ))} + +
+ +} + +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 + } + + return null +} + + +const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => { + + const { setValue, watch } = useFormContext() + + const mirrorTransactionName = watch('mirror_transaction_name') + const paid_from = watch('paid_from') + const paid_to = watch('paid_to') + + const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', { + transaction_id: transaction.name + }, undefined, { + revalidateOnFocus: false, + revalidateIfStale: false, + }) + + // Get bank accounts to find the logo + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + if (data?.message?.bank_account && banks) { + return banks.find(bank => bank.name === data.message.bank_account) + } + return null + }, [data?.message?.bank_account, banks]) + + const selectTransaction = () => { + if (data?.message) { + setValue('mirror_transaction_name', data.message.name) + onAccountChange(data.message.account, true) + } + } + + if (data?.message) { + + const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0 + + const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit + const currency = data.message.currency + + const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account + + const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected + + return (
+
+
+
+
+ + {_("Suggested Transfer to {0}", [data.message.account])} +
+
+ {_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])} + {_("Accepting the suggestion will reconcile both transactions.")} +
+ +
+
+ + {formatDate(data.message.date, 'Do MMM YYYY')} +
+ {data.message.description} +
+
+
+
+
+ +
+
+
+ {isWithdrawal ? : } + {isWithdrawal ? _('Transferred Out') : _('Received')} +
+
+ {formatCurrency(amount, currency)} +
+ +
+
+
+
+ ) + } + + return null +} + +export default TransferModalContent diff --git a/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx b/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx index 23edf987404..63b7df824d0 100644 --- a/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx +++ b/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx @@ -1,6 +1,5 @@ import CSVRawDataPreview from './CSVRawDataPreview' import StatementDetails from './StatementDetails' -import _ from '@/lib/translate' import { GetStatementDetailsResponse } from '../import_utils' const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => { diff --git a/banking/src/components/features/Settings/KeyboardShortcuts.tsx b/banking/src/components/features/Settings/KeyboardShortcuts.tsx index 435a0ac2ab0..71feaf3afc9 100644 --- a/banking/src/components/features/Settings/KeyboardShortcuts.tsx +++ b/banking/src/components/features/Settings/KeyboardShortcuts.tsx @@ -4,7 +4,7 @@ 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' +import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react' const Shortcuts = [ { @@ -32,7 +32,7 @@ const Shortcuts = [ } }, { - shortcut: R, + shortcut: R, action: { icon: , label: _("Accept Matching Rule"), diff --git a/banking/src/components/features/Settings/Preferences.tsx b/banking/src/components/features/Settings/Preferences.tsx index b182485a7ec..5db0e886fe9 100644 --- a/banking/src/components/features/Settings/Preferences.tsx +++ b/banking/src/components/features/Settings/Preferences.tsx @@ -20,7 +20,7 @@ export const Preferences = () => { const { updateDoc, error } = useFrappeUpdateDoc() - const onUpdate = (field: keyof AccountsSettings, value: any) => { + const onUpdate = (field: K, value: AccountsSettings[K]) => { mutate(updateDoc("Accounts Settings", "Accounts Settings", { [field]: value }), { diff --git a/banking/src/components/features/Settings/Settings.tsx b/banking/src/components/features/Settings/Settings.tsx index 623fceea4ac..acdb4e39dc1 100644 --- a/banking/src/components/features/Settings/Settings.tsx +++ b/banking/src/components/features/Settings/Settings.tsx @@ -1,95 +1,42 @@ 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 { SettingsIcon } 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' +import SettingsDialogContent from './SettingsDialogContent' const Settings = () => { + const [isOpen, setIsOpen] = useState(false) - const [isOpen, setIsOpen] = useState(false) + useHotkeys('shift+meta+g', () => { + setIsOpen(x => !x) + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: 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" - /> - - - - - - - - - - - - - - - - - - - ) + return ( + + + + + + + + + {_("Settings")} + + + {isOpen && ( + setIsOpen(false)} /> + )} + + ) } export default Settings diff --git a/banking/src/components/features/Settings/SettingsDialogContent.tsx b/banking/src/components/features/Settings/SettingsDialogContent.tsx new file mode 100644 index 00000000000..433ecb8691c --- /dev/null +++ b/banking/src/components/features/Settings/SettingsDialogContent.tsx @@ -0,0 +1,52 @@ +import { + SettingsDialog, + SettingsPanels, + SettingsTabGroup, + SettingsTabItem, + SettingsTabs, +} from '@/components/ui/settings-dialog' +import _ from '@/lib/translate' +import { KeyboardIcon, Loader2Icon, SlidersVerticalIcon, ZapIcon } from 'lucide-react' +import { lazy, Suspense } from 'react' + +const SettingsPanelsContent = lazy(() => import('./SettingsPanelsContent')) + +const SettingsPanelsFallback = () => ( +
+ +
+) + +const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => { + return ( + + + + } + label={_("Preferences")} + value="preferences" + /> + } + label={_("Matching Rules")} + value="rules" + /> + } + label={_("Keyboard Shortcuts")} + value="keyboard-shortcuts" + /> + + + + + }> + + + + + ) +} + +export default SettingsDialogContent diff --git a/banking/src/components/features/Settings/SettingsPanelsContent.tsx b/banking/src/components/features/Settings/SettingsPanelsContent.tsx new file mode 100644 index 00000000000..ac60de5c421 --- /dev/null +++ b/banking/src/components/features/Settings/SettingsPanelsContent.tsx @@ -0,0 +1,24 @@ +import { SettingsPanel } from '@/components/ui/settings-dialog' +import { Preferences } from './Preferences' +import MatchingRules from './MatchingRules' +import KeyboardShortcuts from './KeyboardShortcuts' + +const SettingsPanelsContent = () => { + return ( + <> + + + + + + + + + + + + + ) +} + +export default SettingsPanelsContent diff --git a/banking/src/components/ui/alert-dialog.tsx b/banking/src/components/ui/alert-dialog.tsx index cbab3099bb3..625f0b5eb0f 100644 --- a/banking/src/components/ui/alert-dialog.tsx +++ b/banking/src/components/ui/alert-dialog.tsx @@ -170,7 +170,7 @@ function AlertDialogCancel({ }: React.ComponentProps & Pick, "variant" | "size" | "theme">) { return ( -