mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-03 20:29:09 +00:00
fix(banking): miscellaneous bug fixes (#55492)
* fix(banking): correct usage of hooks in rule action * fix(banking): apply ESLint rules for hooks * fix(banking): add lazy imports and code-splitting
This commit is contained in:
@@ -9,16 +9,18 @@ export default defineConfig([
|
|||||||
globalIgnores(["dist"]),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
|
||||||
js.configs.recommended,
|
plugins: {
|
||||||
tseslint.configs.recommended,
|
"react-hooks": reactHooks,
|
||||||
reactHooks.configs.flat.recommended,
|
},
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
onlyExportComponents: false,
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"react-refresh/only-export-components": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.15.0",
|
"react-router": "^7.15.0",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.0",
|
||||||
"react-virtuoso": "^4.18.6",
|
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -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;
|
const { webserver_port } = common_site_config;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
'^/(app|api|assets|files|private)': {
|
'^/(app|api|assets|files|private)': {
|
||||||
target: `http://127.0.0.1:${webserver_port}`,
|
target: `http://127.0.0.1:${webserver_port}`,
|
||||||
ws: true,
|
ws: true,
|
||||||
router: function(req) {
|
router: function (req) {
|
||||||
const site_name = req.headers.host.split(':')[0];
|
const site_name = req.headers?.host?.split(':')[0];
|
||||||
return `http://${site_name}:${webserver_port}`;
|
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useEffect } from 'react'
|
import { lazy, useEffect } from 'react'
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { FrappeProvider } from 'frappe-react-sdk'
|
import { FrappeProvider } from 'frappe-react-sdk'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import BankReconciliation from '@/pages/BankReconciliation'
|
import BankReconciliation from '@/pages/BankReconciliation'
|
||||||
|
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
|
||||||
import { TooltipProvider } from './components/ui/tooltip'
|
import { TooltipProvider } from './components/ui/tooltip'
|
||||||
import BankStatementImporter from '@/pages/BankStatementImporter'
|
|
||||||
import { LucideProvider } from 'lucide-react'
|
import { LucideProvider } from 'lucide-react'
|
||||||
import { ThemeProvider } from './components/ui/theme-provider'
|
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() {
|
function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +44,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
|
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
|
||||||
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
|
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<BankReconciliation />} />
|
<Route index element={<BankReconciliation />} />
|
||||||
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
||||||
|
|||||||
@@ -1,475 +1,42 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { useAtomValue, useSetAtom } from 'jotai'
|
import { HistoryIcon } from 'lucide-react'
|
||||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
import ActionLogDialog from './ActionLogDialog'
|
||||||
import { getCompanyCurrency } from '@/lib/company'
|
|
||||||
import { formatCurrency } from '@/lib/numbers'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { formatDate } from '@/lib/date'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { slug } from '@/lib/frappe'
|
|
||||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
|
||||||
import { JournalEntry } from '@/types/Accounts/JournalEntry'
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
|
||||||
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
||||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
|
||||||
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { getErrorMessage } from '@/lib/frappe'
|
|
||||||
import ErrorBanner from '@/components/ui/error-banner'
|
|
||||||
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
|
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
|
||||||
import BankLogo from '@/components/common/BankLogo'
|
|
||||||
|
|
||||||
const ActionLog = () => {
|
const 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', () => {
|
return (
|
||||||
setIsOpen(true)
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
}, {
|
<Tooltip>
|
||||||
enabled: true,
|
<TooltipTrigger asChild>
|
||||||
enableOnFormTags: false,
|
<DialogTrigger asChild>
|
||||||
preventDefault: true
|
<Button variant={'outline'} isIconButton size='md'>
|
||||||
})
|
<HistoryIcon />
|
||||||
|
</Button>
|
||||||
return (
|
</DialogTrigger>
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
</TooltipTrigger>
|
||||||
<Tooltip>
|
<TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
{_("Reconciliation History")}
|
||||||
<DialogTrigger asChild>
|
</TooltipContent>
|
||||||
<Button variant={'outline'} isIconButton size='md'>
|
</Tooltip>
|
||||||
<HistoryIcon />
|
{isOpen && (
|
||||||
</Button>
|
<ActionLogDialog onClose={() => setIsOpen(false)} />
|
||||||
</DialogTrigger>
|
)}
|
||||||
</TooltipTrigger>
|
</Dialog>
|
||||||
<TooltipContent>
|
)
|
||||||
{_("Reconciliation History")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DialogContent className='min-w-[90vw]'>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
|
|
||||||
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<ActionLogDialogContent />
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant={'outline'} size='md' onClick={() => setIsOpen(false)}>{_("Close")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActionLogDialogContent = () => {
|
|
||||||
|
|
||||||
const actionLog = useAtomValue(bankRecActionLog)
|
|
||||||
|
|
||||||
return <div className='flex flex-col gap-2'>
|
|
||||||
{actionLog.map((action) => (
|
|
||||||
<div key={action.timestamp} className='flex flex-col gap-1'>
|
|
||||||
<ActionGroupHeader action={action} />
|
|
||||||
<div>
|
|
||||||
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
|
|
||||||
<div className='ms-5'>
|
|
||||||
{action.items.map((item, index) => (
|
|
||||||
<Row
|
|
||||||
item={item}
|
|
||||||
key={item.bankTransaction.name}
|
|
||||||
index={index}
|
|
||||||
action={action}
|
|
||||||
isLast={index === action.items.length - 1} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{actionLog.length === 0 && <Empty>
|
|
||||||
<EmptyMedia>
|
|
||||||
<HistoryIcon />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
|
|
||||||
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 <div className='flex items-center gap-2 text-ink-gray-5'>
|
|
||||||
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
|
|
||||||
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
|
|
||||||
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
|
|
||||||
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
|
|
||||||
<span className='flex items-center gap-2 text-sm'>
|
|
||||||
{label} - {dayjs(action.timestamp).fromNow()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <div className='flex items-center gap-2 group'>
|
|
||||||
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
|
|
||||||
<div className='flex justify-between items-center'>
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<p className='text-p-base'>{item.bankTransaction.description}</p>
|
|
||||||
<div className='flex items-center gap-3'>
|
|
||||||
<div className='flex gap-2 items-center'>
|
|
||||||
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
|
|
||||||
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
|
|
||||||
</div>
|
|
||||||
<Separator orientation='vertical' />
|
|
||||||
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
|
|
||||||
<CalendarIcon className='w-4 h-4' />
|
|
||||||
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
|
|
||||||
</div>
|
|
||||||
<Separator orientation='vertical' />
|
|
||||||
<div>
|
|
||||||
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
|
|
||||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
|
||||||
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-end items-center gap-2'>
|
|
||||||
<div className='text-end flex flex-col gap-2'>
|
|
||||||
<a
|
|
||||||
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
|
|
||||||
target='_blank'
|
|
||||||
className='underline underline-offset-4 text-base'>
|
|
||||||
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
|
|
||||||
</a>
|
|
||||||
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
|
|
||||||
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='w-10 h-10 flex items-center justify-center'>
|
|
||||||
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
|
||||||
|
|
||||||
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
|
|
||||||
<WalletIcon className='w-4 h-4' />
|
|
||||||
<JournalEntryAccountsTable item={item} bank={bank} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ? <span className='text-sm'>{accounts[0].account}</span> :
|
|
||||||
<HoverCard>
|
|
||||||
<HoverCardTrigger>
|
|
||||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className='w-full p-2' align='end'>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{_("Account")}</TableHead>
|
|
||||||
<TableHead className='text-end'>{_("Debit")}</TableHead>
|
|
||||||
<TableHead className='text-end'>{_("Credit")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<TableRow key={account.account}>
|
|
||||||
<TableCell>{account.account}</TableCell>
|
|
||||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
|
|
||||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
|
||||||
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
|
|
||||||
return <TransferDetails item={item} className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <div className='flex items-center gap-3'>
|
|
||||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
|
||||||
<UserIcon className='w-4 h-4' />
|
|
||||||
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
|
|
||||||
</div>
|
|
||||||
<Separator orientation='vertical' />
|
|
||||||
<HoverCard>
|
|
||||||
<HoverCardTrigger>
|
|
||||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
|
||||||
<ReceiptTextIcon className='w-4 h-4' />
|
|
||||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
|
|
||||||
</div>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className='w-full p-2' align='end'>
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
{invoices.map((invoice) => (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{_("Document")}</TableHead>
|
|
||||||
<TableHead>{_("Invoice No")}</TableHead>
|
|
||||||
<TableHead>{_("Due Date")}</TableHead>
|
|
||||||
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
|
|
||||||
<TableHead className='text-end'>{_("Allocated")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
|
|
||||||
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
|
|
||||||
<TableCell>{formatDate(invoice.due_date)}</TableCell>
|
|
||||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
|
|
||||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
|
||||||
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
|
|
||||||
<span className='text-sm'>{bank?.account}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={'ghost'}
|
|
||||||
isIconButton
|
|
||||||
theme='red'
|
|
||||||
title={_("Cancel")}
|
|
||||||
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
|
|
||||||
<CircleXIcon className='w-8 h-8' />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{_("Cancel")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<AlertDialogContent className='min-w-3xl'>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{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])}</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
{error && <ErrorBanner error={error} />}
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<SelectedTransactionDetails transaction={item.bankTransaction} />
|
|
||||||
<Table>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{_("Action Type")}</TableHead>
|
|
||||||
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{_("Voucher Type")}</TableHead>
|
|
||||||
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{_("Voucher Name")}</TableHead>
|
|
||||||
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{_("Posting Date")}</TableHead>
|
|
||||||
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{type === 'transfer' && item.voucher.doc && <TableRow>
|
|
||||||
<TableHead>{_("Transfer Account")}</TableHead>
|
|
||||||
<TableCell>
|
|
||||||
<TransferDetails item={item} className='text-ink-gray-8' />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>}
|
|
||||||
{type === 'payment' && item.voucher.doc && <TableRow>
|
|
||||||
<TableHead>{_("Payment Details")}</TableHead>
|
|
||||||
<TableCell>
|
|
||||||
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>}
|
|
||||||
{type === 'bank_entry' && item.voucher.doc && <TableRow>
|
|
||||||
<TableHead>{_("Account")}</TableHead>
|
|
||||||
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
|
|
||||||
</TableRow>}
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={loading}>
|
|
||||||
{_("Close")}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
|
|
||||||
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ActionLog
|
export default ActionLog
|
||||||
@@ -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 = () => (
|
||||||
|
<div className="flex flex-1 items-center justify-center min-h-[40vh]">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ActionLogDialog = ({ onClose }: { onClose: () => void }) => {
|
||||||
|
return (
|
||||||
|
<DialogContent className='min-w-[90vw]'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
|
||||||
|
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Suspense fallback={<ActionLogDialogFallback />}>
|
||||||
|
<ActionLogDialogBody />
|
||||||
|
</Suspense>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant={'outline'} size='md' onClick={onClose}>{_("Close")}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionLogDialog
|
||||||
@@ -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 <div className='flex flex-col gap-2'>
|
||||||
|
{actionLog.map((action) => (
|
||||||
|
<div key={action.timestamp} className='flex flex-col gap-1'>
|
||||||
|
<ActionGroupHeader action={action} />
|
||||||
|
<div>
|
||||||
|
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
|
||||||
|
<div className='ms-5'>
|
||||||
|
{action.items.map((item, index) => (
|
||||||
|
<Row
|
||||||
|
item={item}
|
||||||
|
key={item.bankTransaction.name}
|
||||||
|
index={index}
|
||||||
|
action={action}
|
||||||
|
isLast={index === action.items.length - 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{actionLog.length === 0 && <Empty>
|
||||||
|
<EmptyMedia>
|
||||||
|
<HistoryIcon />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
|
||||||
|
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 <div className='flex items-center gap-2 text-ink-gray-5'>
|
||||||
|
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
|
||||||
|
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
|
||||||
|
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
|
||||||
|
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
|
||||||
|
<span className='flex items-center gap-2 text-sm'>
|
||||||
|
{label} - {dayjs(action.timestamp).fromNow()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className='flex items-center gap-2 group'>
|
||||||
|
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<p className='text-p-base'>{item.bankTransaction.description}</p>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
|
||||||
|
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
|
||||||
|
</div>
|
||||||
|
<Separator orientation='vertical' />
|
||||||
|
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
|
||||||
|
<CalendarIcon className='w-4 h-4' />
|
||||||
|
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
|
||||||
|
</div>
|
||||||
|
<Separator orientation='vertical' />
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
|
||||||
|
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||||
|
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end items-center gap-2'>
|
||||||
|
<div className='text-end flex flex-col gap-2'>
|
||||||
|
<a
|
||||||
|
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
|
||||||
|
target='_blank'
|
||||||
|
className='underline underline-offset-4 text-base'>
|
||||||
|
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
|
||||||
|
</a>
|
||||||
|
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
|
||||||
|
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='w-10 h-10 flex items-center justify-center'>
|
||||||
|
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||||
|
|
||||||
|
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
|
||||||
|
<WalletIcon className='w-4 h-4' />
|
||||||
|
<JournalEntryAccountsTable item={item} bank={bank} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? <span className='text-sm'>{accounts[0].account}</span> :
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger>
|
||||||
|
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className='w-full p-2' align='end'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Account")}</TableHead>
|
||||||
|
<TableHead className='text-end'>{_("Debit")}</TableHead>
|
||||||
|
<TableHead className='text-end'>{_("Credit")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<TableRow key={account.account}>
|
||||||
|
<TableCell>{account.account}</TableCell>
|
||||||
|
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||||
|
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||||
|
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
|
||||||
|
return <TransferDetails item={item} className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className='flex items-center gap-3'>
|
||||||
|
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||||
|
<UserIcon className='w-4 h-4' />
|
||||||
|
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
|
||||||
|
</div>
|
||||||
|
<Separator orientation='vertical' />
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger>
|
||||||
|
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||||
|
<ReceiptTextIcon className='w-4 h-4' />
|
||||||
|
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className='w-full p-2' align='end'>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Document")}</TableHead>
|
||||||
|
<TableHead>{_("Invoice No")}</TableHead>
|
||||||
|
<TableHead>{_("Due Date")}</TableHead>
|
||||||
|
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
|
||||||
|
<TableHead className='text-end'>{_("Allocated")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
|
||||||
|
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.due_date)}</TableCell>
|
||||||
|
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
|
||||||
|
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||||
|
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
|
||||||
|
<span className='text-sm'>{bank?.account}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={'ghost'}
|
||||||
|
isIconButton
|
||||||
|
theme='red'
|
||||||
|
title={_("Cancel")}
|
||||||
|
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
|
||||||
|
<CircleXIcon className='w-8 h-8' />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{_("Cancel")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<AlertDialogContent className='min-w-3xl'>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{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])}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{error && <ErrorBanner error={error} />}
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<SelectedTransactionDetails transaction={item.bankTransaction} />
|
||||||
|
<Table>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Action Type")}</TableHead>
|
||||||
|
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Voucher Type")}</TableHead>
|
||||||
|
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Voucher Name")}</TableHead>
|
||||||
|
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Posting Date")}</TableHead>
|
||||||
|
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{type === 'transfer' && item.voucher.doc && <TableRow>
|
||||||
|
<TableHead>{_("Transfer Account")}</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<TransferDetails item={item} className='text-ink-gray-8' />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>}
|
||||||
|
{type === 'payment' && item.voucher.doc && <TableRow>
|
||||||
|
<TableHead>{_("Payment Details")}</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>}
|
||||||
|
{type === 'bank_entry' && item.voucher.doc && <TableRow>
|
||||||
|
<TableHead>{_("Account")}</TableHead>
|
||||||
|
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
|
||||||
|
</TableRow>}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>
|
||||||
|
{_("Close")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
|
||||||
|
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionLogDialogBody
|
||||||
@@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => {
|
|||||||
toast.success(_("Copied to clipboard"))
|
toast.success(_("Copied to clipboard"))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[copyToClipboard, _],
|
[copyToClipboard],
|
||||||
)
|
)
|
||||||
|
|
||||||
const accountCurrency = useMemo(
|
const accountCurrency = useMemo(
|
||||||
@@ -200,7 +200,7 @@ const BankClearanceSummaryView = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, accountCurrency, bankAccount, companyID, mutate, onCopy],
|
[accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <div className="space-y-4 py-2">
|
return <div className="space-y-4 py-2">
|
||||||
|
|||||||
@@ -1,831 +1,32 @@
|
|||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
|
||||||
|
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
import { lazy, Suspense } from "react"
|
||||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
|
||||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
|
||||||
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
|
|
||||||
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import ErrorBanner from "@/components/ui/error-banner"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
|
||||||
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
|
|
||||||
import { Form } from "@/components/ui/form"
|
|
||||||
import { useCallback, useContext, useMemo, useRef, useState } from "react"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
|
||||||
import { flt, formatCurrency } from "@/lib/numbers"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
|
||||||
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
|
|
||||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
|
||||||
import FileUploadBanner from "@/components/common/FileUploadBanner"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
|
||||||
import { useGetAccounts } from "@/components/common/AccountsDropdown"
|
|
||||||
import { useHotkeys } from "react-hotkeys-hook"
|
|
||||||
|
|
||||||
const BankEntryModal = () => {
|
const BankEntryModal = () => {
|
||||||
|
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
return (
|
<DialogContent className='min-w-[95vw]'>
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<DialogHeader>
|
||||||
<DialogContent className='min-w-[95vw]'>
|
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||||
<DialogHeader>
|
<DialogDescription>
|
||||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||||
<DialogDescription>
|
</DialogDescription>
|
||||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
</DialogHeader>
|
||||||
</DialogDescription>
|
{isOpen && (
|
||||||
</DialogHeader>
|
<Suspense fallback={<ModalContentFallback />}>
|
||||||
<RecordBankEntryModalContent />
|
<RecordBankEntryModalContent />
|
||||||
</DialogContent>
|
</Suspense>
|
||||||
</Dialog>
|
)}
|
||||||
)
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const RecordBankEntryModalContent = () => {
|
|
||||||
|
|
||||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
|
||||||
|
|
||||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
|
||||||
|
|
||||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
|
||||||
return <div className='p-4'>
|
|
||||||
<span className='text-center'>{_("No transaction selected")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTransaction.length === 1) {
|
|
||||||
return <BankEntryForm
|
|
||||||
selectedTransaction={selectedTransaction[0]} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <BulkBankEntryForm
|
|
||||||
selectedTransactions={selectedTransaction}
|
|
||||||
/>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{error && <ErrorBanner error={error} />}
|
|
||||||
<SelectedTransactionsTable />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<AccountFormField
|
|
||||||
name='account'
|
|
||||||
filterFunction={(acc) => {
|
|
||||||
// Do not allow payable and receivable accounts
|
|
||||||
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
|
|
||||||
}}
|
|
||||||
label={_('Account')}
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
|
|
||||||
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<JournalEntryAccount>[] = [
|
|
||||||
{
|
|
||||||
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<BankEntryFormData>({
|
|
||||||
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<File[]>([])
|
|
||||||
|
|
||||||
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 <FileUploadBanner uploadProgress={uploadProgress} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
{error && <ErrorBanner error={error} />}
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<DateField
|
|
||||||
name='posting_date'
|
|
||||||
label={_("Posting Date")}
|
|
||||||
isRequired
|
|
||||||
inputProps={{ autoFocus: false }}
|
|
||||||
/>
|
|
||||||
<DateField
|
|
||||||
name='cheque_date'
|
|
||||||
label={_("Reference Date")}
|
|
||||||
isRequired
|
|
||||||
inputProps={{ autoFocus: false }}
|
|
||||||
rules={{
|
|
||||||
required: _("Reference Date is required"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
|
|
||||||
rules={{
|
|
||||||
required: _("Reference is required"),
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<SmallTextField
|
|
||||||
name='user_remark'
|
|
||||||
label={_("Remarks")}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-slot="form-item"
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<Label>{_("Attachments")}</Label>
|
|
||||||
<FileDropzone files={files} setFiles={setFiles} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
|
|
||||||
|
|
||||||
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
|
|
||||||
|
|
||||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
|
||||||
|
|
||||||
const partyMapRef = useRef<Record<string, string>>({})
|
|
||||||
|
|
||||||
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<number[]>([])
|
|
||||||
|
|
||||||
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 <div className="flex flex-col gap-2">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead><Checkbox
|
|
||||||
disabled={fields.length === 0}
|
|
||||||
// Make this accessible to screen readers
|
|
||||||
aria-label={_("Select all")}
|
|
||||||
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
|
|
||||||
onCheckedChange={onSelectAll} /></TableHead>
|
|
||||||
<TableHead>{_("Party")}</TableHead>
|
|
||||||
<TableHead>{_("Account")}</TableHead>
|
|
||||||
<TableHead>{_("Cost Center")}</TableHead>
|
|
||||||
<TableHead>{_("Remarks")}</TableHead>
|
|
||||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
|
||||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
|
|
||||||
<TableCell>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedRows.includes(index)}
|
|
||||||
onCheckedChange={() => onSelectRow(index)}
|
|
||||||
// Make this accessible to screen readers
|
|
||||||
aria-label={_("Select row {0}", [String(index + 1)])}
|
|
||||||
disabled={index === 0}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="align-top">
|
|
||||||
<div className="flex">
|
|
||||||
<PartyTypeFormField
|
|
||||||
name={`entries.${index}.party_type`}
|
|
||||||
label={_("Party Type")}
|
|
||||||
isRequired
|
|
||||||
readOnly={index === 0}
|
|
||||||
hideLabel
|
|
||||||
inputProps={{
|
|
||||||
type: isWithdrawal ? 'Payable' : 'Receivable',
|
|
||||||
triggerProps: {
|
|
||||||
className: 'rounded-e-none',
|
|
||||||
tabIndex: -1
|
|
||||||
},
|
|
||||||
readOnly: index === 0,
|
|
||||||
}} />
|
|
||||||
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="align-top">
|
|
||||||
<AccountFormField
|
|
||||||
name={`entries.${index}.account`}
|
|
||||||
label={_("Account")}
|
|
||||||
rules={{
|
|
||||||
required: _("Account is required"),
|
|
||||||
onChange: (event) => {
|
|
||||||
onAccountChange(event.target.value, index)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
buttonClassName="min-w-64"
|
|
||||||
readOnly={index === 0}
|
|
||||||
isRequired
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="align-top">
|
|
||||||
<LinkFormField
|
|
||||||
doctype="Cost Center"
|
|
||||||
name={`entries.${index}.cost_center`}
|
|
||||||
label={_("Cost Center")}
|
|
||||||
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
|
|
||||||
buttonClassName="min-w-48"
|
|
||||||
readOnly={index === 0}
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="align-top">
|
|
||||||
<DataField
|
|
||||||
name={`entries.${index}.user_remark`}
|
|
||||||
label={_("Remarks")}
|
|
||||||
readOnly={index === 0}
|
|
||||||
inputProps={{
|
|
||||||
placeholder: _("e.g. Bank Charges"),
|
|
||||||
className: 'min-w-64',
|
|
||||||
readOnly: index === 0
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={cn("text-end align-top")}>
|
|
||||||
<CurrencyFormField
|
|
||||||
name={`entries.${index}.debit`}
|
|
||||||
label={_("Debit")}
|
|
||||||
isRequired
|
|
||||||
hideLabel
|
|
||||||
readOnly={index === 0}
|
|
||||||
style={index === 0 ? !isWithdrawal ? {
|
|
||||||
color: "var(--color-ink-gray-8)",
|
|
||||||
} : {} : {}}
|
|
||||||
currency={currency}
|
|
||||||
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
|
|
||||||
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
|
|
||||||
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
|
|
||||||
</Tooltip> : undefined}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={cn("text-end align-top")}>
|
|
||||||
<CurrencyFormField
|
|
||||||
name={`entries.${index}.credit`}
|
|
||||||
style={index === 0 && isWithdrawal ? {
|
|
||||||
color: "var(--color-ink-gray-8)",
|
|
||||||
} : {}}
|
|
||||||
label={_("Credit")}
|
|
||||||
isRequired
|
|
||||||
hideLabel
|
|
||||||
readOnly={index === 0}
|
|
||||||
currency={currency}
|
|
||||||
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
|
|
||||||
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
|
|
||||||
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
|
|
||||||
</Tooltip> : undefined}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="flex justify-between gap-2">
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<div>
|
|
||||||
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
|
|
||||||
</div>
|
|
||||||
{selectedRows.length > 0 && <div>
|
|
||||||
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
<Summary currency={currency} addRow={onAddDifferenceClicked} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
|
|
||||||
|
|
||||||
const { control } = useFormContext<BankEntryFormData>()
|
|
||||||
|
|
||||||
const party_type = useWatch({
|
|
||||||
control,
|
|
||||||
name: `entries.${index}.party_type`
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!party_type) {
|
|
||||||
return <DataField
|
|
||||||
name={`entries.${index}.party`}
|
|
||||||
label={_("Party")}
|
|
||||||
isRequired
|
|
||||||
inputProps={{
|
|
||||||
disabled: true,
|
|
||||||
className: 'rounded-s-none border-s-0 min-w-64'
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <LinkFormField
|
|
||||||
name={`entries.${index}.party`}
|
|
||||||
label={_("Party")}
|
|
||||||
rules={{
|
|
||||||
onChange: (event) => {
|
|
||||||
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<BankEntryFormData>()
|
|
||||||
|
|
||||||
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 <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="flex flex-col gap-2 items-end">
|
|
||||||
<div className="flex gap-2 justify-between">
|
|
||||||
<TextComponent>{_("Total Debit")}</TextComponent>
|
|
||||||
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-between">
|
|
||||||
<TextComponent>{_("Total Credit")}</TextComponent>
|
|
||||||
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
|
|
||||||
</div>
|
|
||||||
{total !== 0 && <div className="flex gap-2 justify-between">
|
|
||||||
<TextComponent>{_("Difference")}</TextComponent>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
|
|
||||||
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{_("Add a row with the difference amount")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default BankEntryModal
|
export default BankEntryModal
|
||||||
|
|||||||
@@ -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 <div className='p-4'>
|
||||||
|
<span className='text-center'>{_("No transaction selected")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTransaction.length === 1) {
|
||||||
|
return <BankEntryForm
|
||||||
|
selectedTransaction={selectedTransaction[0]} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BulkBankEntryForm
|
||||||
|
selectedTransactions={selectedTransaction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{error && <ErrorBanner error={error} />}
|
||||||
|
<SelectedTransactionsTable />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<AccountFormField
|
||||||
|
name='account'
|
||||||
|
filterFunction={(acc) => {
|
||||||
|
// Do not allow payable and receivable accounts
|
||||||
|
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
|
||||||
|
}}
|
||||||
|
label={_('Account')}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
|
||||||
|
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<JournalEntryAccount>[] = [
|
||||||
|
{
|
||||||
|
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<BankEntryFormData>({
|
||||||
|
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<File[]>([])
|
||||||
|
|
||||||
|
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 <FileUploadBanner uploadProgress={uploadProgress} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{error && <ErrorBanner error={error} />}
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<DateField
|
||||||
|
name='posting_date'
|
||||||
|
label={_("Posting Date")}
|
||||||
|
isRequired
|
||||||
|
inputProps={{ autoFocus: false }}
|
||||||
|
/>
|
||||||
|
<DateField
|
||||||
|
name='cheque_date'
|
||||||
|
label={_("Reference Date")}
|
||||||
|
isRequired
|
||||||
|
inputProps={{ autoFocus: false }}
|
||||||
|
rules={{
|
||||||
|
required: _("Reference Date is required"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
|
||||||
|
rules={{
|
||||||
|
required: _("Reference is required"),
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<SmallTextField
|
||||||
|
name='user_remark'
|
||||||
|
label={_("Remarks")}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<Label>{_("Attachments")}</Label>
|
||||||
|
<FileDropzone files={files} setFiles={setFiles} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
|
||||||
|
|
||||||
|
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
|
||||||
|
|
||||||
|
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||||
|
|
||||||
|
const partyMapRef = useRef<Record<string, string>>({})
|
||||||
|
|
||||||
|
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<number[]>([])
|
||||||
|
|
||||||
|
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 <div className="flex flex-col gap-2">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead><Checkbox
|
||||||
|
disabled={fields.length === 0}
|
||||||
|
// Make this accessible to screen readers
|
||||||
|
aria-label={_("Select all")}
|
||||||
|
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
|
||||||
|
onCheckedChange={onSelectAll} /></TableHead>
|
||||||
|
<TableHead>{_("Party")}</TableHead>
|
||||||
|
<TableHead>{_("Account")}</TableHead>
|
||||||
|
<TableHead>{_("Cost Center")}</TableHead>
|
||||||
|
<TableHead>{_("Remarks")}</TableHead>
|
||||||
|
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||||
|
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.includes(index)}
|
||||||
|
onCheckedChange={() => onSelectRow(index)}
|
||||||
|
// Make this accessible to screen readers
|
||||||
|
aria-label={_("Select row {0}", [String(index + 1)])}
|
||||||
|
disabled={index === 0}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<div className="flex">
|
||||||
|
<PartyTypeFormField
|
||||||
|
name={`entries.${index}.party_type`}
|
||||||
|
label={_("Party Type")}
|
||||||
|
isRequired
|
||||||
|
readOnly={index === 0}
|
||||||
|
hideLabel
|
||||||
|
inputProps={{
|
||||||
|
type: isWithdrawal ? 'Payable' : 'Receivable',
|
||||||
|
triggerProps: {
|
||||||
|
className: 'rounded-e-none',
|
||||||
|
tabIndex: -1
|
||||||
|
},
|
||||||
|
readOnly: index === 0,
|
||||||
|
}} />
|
||||||
|
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<AccountFormField
|
||||||
|
name={`entries.${index}.account`}
|
||||||
|
label={_("Account")}
|
||||||
|
rules={{
|
||||||
|
required: _("Account is required"),
|
||||||
|
onChange: (event) => {
|
||||||
|
onAccountChange(event.target.value, index)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
buttonClassName="min-w-64"
|
||||||
|
readOnly={index === 0}
|
||||||
|
isRequired
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<LinkFormField
|
||||||
|
doctype="Cost Center"
|
||||||
|
name={`entries.${index}.cost_center`}
|
||||||
|
label={_("Cost Center")}
|
||||||
|
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
|
||||||
|
buttonClassName="min-w-48"
|
||||||
|
readOnly={index === 0}
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<DataField
|
||||||
|
name={`entries.${index}.user_remark`}
|
||||||
|
label={_("Remarks")}
|
||||||
|
readOnly={index === 0}
|
||||||
|
inputProps={{
|
||||||
|
placeholder: _("e.g. Bank Charges"),
|
||||||
|
className: 'min-w-64',
|
||||||
|
readOnly: index === 0
|
||||||
|
}}
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={cn("text-end align-top")}>
|
||||||
|
<CurrencyFormField
|
||||||
|
name={`entries.${index}.debit`}
|
||||||
|
label={_("Debit")}
|
||||||
|
isRequired
|
||||||
|
hideLabel
|
||||||
|
readOnly={index === 0}
|
||||||
|
style={index === 0 ? !isWithdrawal ? {
|
||||||
|
color: "var(--color-ink-gray-8)",
|
||||||
|
} : {} : {}}
|
||||||
|
currency={currency}
|
||||||
|
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
|
||||||
|
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
|
||||||
|
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
|
||||||
|
</Tooltip> : undefined}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={cn("text-end align-top")}>
|
||||||
|
<CurrencyFormField
|
||||||
|
name={`entries.${index}.credit`}
|
||||||
|
style={index === 0 && isWithdrawal ? {
|
||||||
|
color: "var(--color-ink-gray-8)",
|
||||||
|
} : {}}
|
||||||
|
label={_("Credit")}
|
||||||
|
isRequired
|
||||||
|
hideLabel
|
||||||
|
readOnly={index === 0}
|
||||||
|
currency={currency}
|
||||||
|
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
|
||||||
|
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
|
||||||
|
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
|
||||||
|
</Tooltip> : undefined}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<div>
|
||||||
|
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
|
||||||
|
</div>
|
||||||
|
{selectedRows.length > 0 && <div>
|
||||||
|
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<Summary currency={currency} addRow={onAddDifferenceClicked} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
|
||||||
|
|
||||||
|
const { control } = useFormContext<BankEntryFormData>()
|
||||||
|
|
||||||
|
const party_type = useWatch({
|
||||||
|
control,
|
||||||
|
name: `entries.${index}.party_type`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!party_type) {
|
||||||
|
return <DataField
|
||||||
|
name={`entries.${index}.party`}
|
||||||
|
label={_("Party")}
|
||||||
|
isRequired
|
||||||
|
inputProps={{
|
||||||
|
disabled: true,
|
||||||
|
className: 'rounded-s-none border-s-0 min-w-64'
|
||||||
|
}}
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LinkFormField
|
||||||
|
name={`entries.${index}.party`}
|
||||||
|
label={_("Party")}
|
||||||
|
rules={{
|
||||||
|
onChange: (event) => {
|
||||||
|
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<BankEntryFormData>()
|
||||||
|
|
||||||
|
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 <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="flex flex-col gap-2 items-end">
|
||||||
|
<div className="flex gap-2 justify-between">
|
||||||
|
<TextComponent>{_("Total Debit")}</TextComponent>
|
||||||
|
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-between">
|
||||||
|
<TextComponent>{_("Total Credit")}</TextComponent>
|
||||||
|
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
|
||||||
|
</div>
|
||||||
|
{total !== 0 && <div className="flex gap-2 justify-between">
|
||||||
|
<TextComponent>{_("Difference")}</TextComponent>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
|
||||||
|
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{_("Add a row with the difference amount")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default RecordBankEntryModalContent
|
||||||
@@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => {
|
|||||||
toast.success(_("Copied to clipboard"))
|
toast.success(_("Copied to clipboard"))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[copyToClipboard, _],
|
[copyToClipboard],
|
||||||
)
|
)
|
||||||
|
|
||||||
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||||
@@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => {
|
|||||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, onCopy],
|
[onCopy],
|
||||||
)
|
)
|
||||||
|
|
||||||
const statementRows = useMemo(() => {
|
const statementRows = useMemo(() => {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const BankTransactionListView = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, accountCurrency, onUndo],
|
[accountCurrency, onUndo],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [search, setSearch] = useDebounceValue('', 250)
|
const [search, setSearch] = useDebounceValue('', 250)
|
||||||
|
|||||||
@@ -1,125 +1,52 @@
|
|||||||
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
|
import {
|
||||||
import { useAtom, useAtomValue } from "jotai"
|
AlertDialog,
|
||||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
AlertDialogContent,
|
||||||
import { useMemo } from "react"
|
AlertDialogDescription,
|
||||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
AlertDialogHeader,
|
||||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
AlertDialogTitle,
|
||||||
import { toast } from "sonner"
|
} from "@/components/ui/alert-dialog"
|
||||||
import ErrorBanner from "@/components/ui/error-banner"
|
import { useAtom } from "jotai"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Loader2Icon } from "lucide-react"
|
||||||
import { formatCurrency } from "@/lib/numbers"
|
import { lazy, Suspense } from "react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
|
||||||
import { slug } from "@/lib/frappe"
|
|
||||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
|
|
||||||
|
const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody'))
|
||||||
|
|
||||||
|
const BankTransactionUnreconcileModalFallback = () => (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const BankTransactionUnreconcileModal = () => {
|
const BankTransactionUnreconcileModal = () => {
|
||||||
|
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||||
|
|
||||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
const onOpenChange = (v: boolean) => {
|
||||||
|
if (!v) {
|
||||||
|
setBankRecUnreconcileModal('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onOpenChange = (v: boolean) => {
|
if (!unreconcileModal) {
|
||||||
if (!v) {
|
return null
|
||||||
setBankRecUnreconcileModal('')
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
|
return (
|
||||||
<AlertDialogOverlay />
|
<AlertDialog open onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent className="min-w-2xl">
|
<AlertDialogContent className="min-w-2xl">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{_("Are you sure you want to unreconcile this transaction?")}
|
{_("Are you sure you want to unreconcile this transaction?")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<BankTransactionUnreconcileModalContent />
|
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
|
||||||
</AlertDialogContent>
|
<BankTransactionUnreconcileModalBody />
|
||||||
</AlertDialog>
|
</Suspense>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
}
|
)
|
||||||
|
|
||||||
const BankTransactionUnreconcileModalContent = () => {
|
|
||||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
||||||
const dates = useAtomValue(bankRecDateAtom)
|
|
||||||
|
|
||||||
const { mutate } = useSWRConfig()
|
|
||||||
|
|
||||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
|
||||||
|
|
||||||
const { data: transaction, error } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
|
|
||||||
|
|
||||||
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
|
|
||||||
|
|
||||||
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
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 <div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{error && <ErrorBanner error={error} />}
|
|
||||||
{unreconcileError && <ErrorBanner error={unreconcileError} />}
|
|
||||||
{transaction && <SelectedTransactionDetails transaction={transaction} />}
|
|
||||||
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{_("Document")}</TableHead>
|
|
||||||
<TableHead>{_("Amount")}</TableHead>
|
|
||||||
<TableHead>{_("Reconciliation Type")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{transaction?.payment_entries?.map((voucher) => {
|
|
||||||
return <TableRow key={voucher.name}>
|
|
||||||
<TableCell>
|
|
||||||
<a className="underline underline-offset-4"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
|
|
||||||
>
|
|
||||||
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
|
|
||||||
</a>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
|
|
||||||
<TableCell>{voucher.reconciliation_type === 'Voucher Created' ?
|
|
||||||
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
|
|
||||||
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="py-4">
|
|
||||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <span>The following documents will be <strong>cancelled</strong>:</span>}
|
|
||||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <ol className="ms-6 list-disc [&>li]:mt-2">
|
|
||||||
{vouchersWhichWillBeCancelled?.map((voucher) => {
|
|
||||||
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
|
|
||||||
})}
|
|
||||||
</ol>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading}>
|
|
||||||
{_("Unreconcile")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BankTransactionUnreconcileModal
|
export default BankTransactionUnreconcileModal
|
||||||
@@ -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<BankTransaction>('Bank Transaction', unreconcileModal)
|
||||||
|
|
||||||
|
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
|
||||||
|
|
||||||
|
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{error && <ErrorBanner error={error} />}
|
||||||
|
{unreconcileError && <ErrorBanner error={unreconcileError} />}
|
||||||
|
{transaction && <SelectedTransactionDetails transaction={transaction} />}
|
||||||
|
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Document")}</TableHead>
|
||||||
|
<TableHead>{_("Amount")}</TableHead>
|
||||||
|
<TableHead>{_("Reconciliation Type")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{transaction?.payment_entries?.map((voucher) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={voucher.name}>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
className="underline underline-offset-4"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
|
||||||
|
>
|
||||||
|
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{voucher.reconciliation_type === 'Voucher Created' ?
|
||||||
|
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
|
||||||
|
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="py-4">
|
||||||
|
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||||
|
<span>The following documents will be <strong>cancelled</strong>:</span>
|
||||||
|
)}
|
||||||
|
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||||
|
<ol className="ms-6 list-disc [&>li]:mt-2">
|
||||||
|
{vouchersWhichWillBeCancelled?.map((voucher) => {
|
||||||
|
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading || isLoading}>
|
||||||
|
{_("Unreconcile")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BankTransactionUnreconcileModalBody
|
||||||
@@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[clearClearingDate, mutate, _],
|
[clearClearingDate, mutate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const accountCurrency = useMemo(
|
const accountCurrency = useMemo(
|
||||||
@@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, accountCurrency, onClearClick],
|
[accountCurrency, onClearClick],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <div className="space-y-4 py-2">
|
return <div className="space-y-4 py-2">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import CurrencyInput from 'react-currency-input-field'
|
import CurrencyInput from 'react-currency-input-field'
|
||||||
import { getCurrencySymbol } from "@/lib/currency"
|
import { getCurrencySymbol } from "@/lib/currency"
|
||||||
import { Virtuoso } from 'react-virtuoso'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { formatDate } from "@/lib/date"
|
import { formatDate } from "@/lib/date"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||||
@@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { slug } from "@/lib/frappe"
|
import { slug } from "@/lib/frappe"
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
|
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import TransferModal from "./TransferModal"
|
import TransferModal from "./TransferModal"
|
||||||
import BankEntryModal from "./BankEntryModal"
|
import BankEntryModal from "./BankEntryModal"
|
||||||
import RecordPaymentModal from "./RecordPaymentModal"
|
import RecordPaymentModal from "./RecordPaymentModal"
|
||||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||||
import MatchFilters from "./MatchFilters"
|
import MatchFilters from "./MatchFilters"
|
||||||
import { useHotkeys } from "react-hotkeys-hook"
|
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<T>({
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="overflow-auto contain-strict"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{ height: rowVirtualizer.getTotalSize() }}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
className="absolute top-0 left-0 w-full"
|
||||||
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||||
|
>
|
||||||
|
{children(items[virtualRow.index], virtualRow.index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
||||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||||
@@ -134,6 +187,7 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||||
|
const listHeight = contentHeight - 72
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <UnreconciledTransactionsLoadingState />
|
return <UnreconciledTransactionsLoadingState />
|
||||||
@@ -222,14 +276,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
|||||||
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
|
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.")} />}
|
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
|
||||||
|
|
||||||
<Virtuoso
|
<VirtualizedListBody
|
||||||
data={results}
|
items={results}
|
||||||
itemContent={(_index, transaction) => (
|
height={listHeight}
|
||||||
<UnreconciledTransactionItem transaction={transaction} />
|
estimateSize={74}
|
||||||
)}
|
getItemKey={(transaction) => transaction.name}
|
||||||
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
|
>
|
||||||
totalCount={results?.length}
|
{(transaction) => <UnreconciledTransactionItem transaction={transaction} />}
|
||||||
/>
|
</VirtualizedListBody>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -559,11 +613,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
||||||
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||||
|
|
||||||
if (!rule) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActionIcon = () => {
|
const getActionIcon = () => {
|
||||||
|
if (!rule) return null
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
return <Landmark />
|
return <Landmark />
|
||||||
@@ -577,6 +628,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActionStyles = () => {
|
const getActionStyles = () => {
|
||||||
|
if (!rule) return {}
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
return {
|
return {
|
||||||
@@ -610,6 +662,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleActionClick = () => {
|
const handleActionClick = () => {
|
||||||
|
if (!rule) return
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
setRecordJournalEntryModalOpen(true)
|
setRecordJournalEntryModalOpen(true)
|
||||||
@@ -624,6 +677,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActionDescription = () => {
|
const getActionDescription = () => {
|
||||||
|
if (!rule) return ""
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
return _("Create a journal entry for expenses, income or split transactions")
|
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()
|
handleActionClick()
|
||||||
}, {
|
}, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -647,6 +700,10 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
|
|
||||||
const styles = getActionStyles()
|
const styles = getActionStyles()
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
@@ -721,6 +778,9 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
|||||||
|
|
||||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||||
|
|
||||||
|
const voucherList = vouchers?.message ?? []
|
||||||
|
const listHeight = contentHeight - 120
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorBanner error={error} />
|
return <ErrorBanner error={error} />
|
||||||
}
|
}
|
||||||
@@ -747,7 +807,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
|||||||
<span>or</span>
|
<span>or</span>
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
{vouchers?.message.length === 0 && <Empty className="my-4">
|
{voucherList.length === 0 && <Empty className="my-4">
|
||||||
<EmptyMedia>
|
<EmptyMedia>
|
||||||
<ReceiptIcon />
|
<ReceiptIcon />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
@@ -756,14 +816,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
|||||||
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
|
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>}
|
</Empty>}
|
||||||
<Virtuoso
|
<VirtualizedListBody
|
||||||
data={vouchers?.message}
|
items={voucherList}
|
||||||
itemContent={(index, voucher) => (
|
height={listHeight}
|
||||||
<VoucherItem voucher={voucher} index={index} />
|
estimateSize={121}
|
||||||
)}
|
getItemKey={(voucher) => voucher.name}
|
||||||
style={{ height: contentHeight }}
|
>
|
||||||
totalCount={vouchers?.message.length}
|
{(voucher, index) => <VoucherItem voucher={voucher} index={index} />}
|
||||||
/>
|
</VirtualizedListBody>
|
||||||
</div >
|
</div >
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,555 +1,32 @@
|
|||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
import { lazy, Suspense } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { bankRecTransferModalAtom } from './bankRecAtoms'
|
||||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
|
||||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
const TransferModalContent = lazy(() => import('./TransferModalContent'))
|
||||||
import { useForm, useFormContext, useWatch } from 'react-hook-form'
|
|
||||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import ErrorBanner from '@/components/ui/error-banner'
|
|
||||||
import { H4 } from '@/components/ui/typography'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { Form } from '@/components/ui/form'
|
|
||||||
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
|
|
||||||
import SelectedTransactionsTable from './SelectedTransactionsTable'
|
|
||||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
|
||||||
import { formatDate } from '@/lib/date'
|
|
||||||
import { useContext, useMemo, useState } from 'react'
|
|
||||||
import { formatCurrency } from '@/lib/numbers'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { FileDropzone } from '@/components/ui/file-dropzone'
|
|
||||||
import FileUploadBanner from '@/components/common/FileUploadBanner'
|
|
||||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
import { useDirection } from '@/components/ui/direction'
|
|
||||||
import BankLogo from '@/components/common/BankLogo'
|
|
||||||
|
|
||||||
const TransferModal = () => {
|
const TransferModal = () => {
|
||||||
|
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
|
||||||
|
return (
|
||||||
return (
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<DialogContent className='min-w-7xl'>
|
||||||
<DialogContent className='min-w-7xl'>
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
<DialogDescription>
|
||||||
<DialogDescription>
|
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
</DialogDescription>
|
||||||
</DialogDescription>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
{isOpen && (
|
||||||
<TransferModalContent />
|
<Suspense fallback={<ModalContentFallback />}>
|
||||||
</DialogContent>
|
<TransferModalContent />
|
||||||
</Dialog>
|
</Suspense>
|
||||||
)
|
)}
|
||||||
}
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
const TransferModalContent = () => {
|
)
|
||||||
|
|
||||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
|
||||||
|
|
||||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
|
||||||
|
|
||||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
|
||||||
return <div className='p-4'>
|
|
||||||
<span className='text-center'>{_("No transaction selected")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTransaction.length === 1) {
|
|
||||||
return <InternalTransferForm
|
|
||||||
selectedBankAccount={selectedBankAccount}
|
|
||||||
selectedTransaction={selectedTransaction[0]} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <BulkInternalTransferForm transactions={selectedTransaction} />
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
|
|
||||||
{error && <ErrorBanner error={error} />}
|
|
||||||
|
|
||||||
<SelectedTransactionsTable />
|
|
||||||
|
|
||||||
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</Form>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<InternalTransferFormFields>({
|
|
||||||
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<File[]>([])
|
|
||||||
|
|
||||||
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 <FileUploadBanner uploadProgress={uploadProgress} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
{error && <ErrorBanner error={error} />}
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<DateField
|
|
||||||
name='posting_date'
|
|
||||||
label={_("Posting Date")}
|
|
||||||
isRequired
|
|
||||||
inputProps={{ autoFocus: false }}
|
|
||||||
/>
|
|
||||||
<DateField
|
|
||||||
name='reference_date'
|
|
||||||
label={_("Reference Date")}
|
|
||||||
isRequired
|
|
||||||
inputProps={{ autoFocus: false }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
|
|
||||||
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
|
|
||||||
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-2 py-2'>
|
|
||||||
<div className='flex items-end justify-between gap-4'>
|
|
||||||
<div className='flex-1'>
|
|
||||||
<AccountFormField
|
|
||||||
name="paid_from"
|
|
||||||
label={_("Paid From")}
|
|
||||||
account_type={['Bank', 'Cash']}
|
|
||||||
readOnly={isWithdrawal}
|
|
||||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='pb-2'>
|
|
||||||
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
|
|
||||||
</div>
|
|
||||||
<div className='flex-1'>
|
|
||||||
<AccountFormField
|
|
||||||
name="paid_to"
|
|
||||||
label={_("Paid To")}
|
|
||||||
account_type={['Bank', 'Cash']}
|
|
||||||
isRequired
|
|
||||||
readOnly={!isWithdrawal}
|
|
||||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
|
|
||||||
|
|
||||||
<SmallTextField
|
|
||||||
name='remarks'
|
|
||||||
label={_("Custom Remarks")}
|
|
||||||
formDescription={_("This will be auto-populated if not set.")}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-slot="form-item"
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<Label>{_("Attachments")}</Label>
|
|
||||||
<FileDropzone files={files} setFiles={setFiles} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 <div className='grid grid-cols-4 gap-4'>
|
|
||||||
{banks.map((bank) => (
|
|
||||||
<div
|
|
||||||
className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
|
||||||
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
|
||||||
)}
|
|
||||||
role='button'
|
|
||||||
key={bank.account}
|
|
||||||
onClick={() => onAccountChange(bank.account ?? '')}
|
|
||||||
>
|
|
||||||
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
|
|
||||||
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <div className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
|
||||||
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
|
||||||
)}
|
|
||||||
role='button'
|
|
||||||
onClick={() => setSelectedAccount(account ?? '')}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-center h-10 w-10'>
|
|
||||||
<Banknote size='24px' />
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<span className='font-semibold text-sm'>Cash</span>
|
|
||||||
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
|
|
||||||
|
|
||||||
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
|
|
||||||
|
|
||||||
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 (<div className='pb-2'>
|
|
||||||
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
|
|
||||||
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
|
|
||||||
<div>
|
|
||||||
<div className='flex flex-col gap-3'>
|
|
||||||
<div className={cn("flex items-center gap-2 shrink-0",
|
|
||||||
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
|
|
||||||
)}>
|
|
||||||
<BadgeCheck className="w-4 h-4" />
|
|
||||||
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
|
|
||||||
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-1.5'>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<Calendar size='16px' />
|
|
||||||
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
|
||||||
</div>
|
|
||||||
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
|
|
||||||
</div>
|
|
||||||
<div className='flex gap-1'>
|
|
||||||
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
|
|
||||||
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
|
|
||||||
)}>
|
|
||||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
|
||||||
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
|
||||||
<div className='pt-1'>
|
|
||||||
<Button
|
|
||||||
onClick={selectTransaction}
|
|
||||||
theme={isSuggested ? "green" : "violet"}
|
|
||||||
size="md"
|
|
||||||
type='button'
|
|
||||||
>
|
|
||||||
{isSuggested ? <CheckCircle /> : <CheckIcon />}
|
|
||||||
{isSuggested ? _("Accepted") : _("Use Suggestion")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TransferModal
|
export default TransferModal
|
||||||
@@ -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 <div className='p-4'>
|
||||||
|
<span className='text-center'>{_("No transaction selected")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTransaction.length === 1) {
|
||||||
|
return <InternalTransferForm
|
||||||
|
selectedBankAccount={selectedBankAccount}
|
||||||
|
selectedTransaction={selectedTransaction[0]} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BulkInternalTransferForm transactions={selectedTransaction} />
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
|
||||||
|
{error && <ErrorBanner error={error} />}
|
||||||
|
|
||||||
|
<SelectedTransactionsTable />
|
||||||
|
|
||||||
|
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InternalTransferFormFields>({
|
||||||
|
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<File[]>([])
|
||||||
|
|
||||||
|
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 <FileUploadBanner uploadProgress={uploadProgress} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{error && <ErrorBanner error={error} />}
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<DateField
|
||||||
|
name='posting_date'
|
||||||
|
label={_("Posting Date")}
|
||||||
|
isRequired
|
||||||
|
inputProps={{ autoFocus: false }}
|
||||||
|
/>
|
||||||
|
<DateField
|
||||||
|
name='reference_date'
|
||||||
|
label={_("Reference Date")}
|
||||||
|
isRequired
|
||||||
|
inputProps={{ autoFocus: false }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
|
||||||
|
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
|
||||||
|
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-2 py-2'>
|
||||||
|
<div className='flex items-end justify-between gap-4'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<AccountFormField
|
||||||
|
name="paid_from"
|
||||||
|
label={_("Paid From")}
|
||||||
|
account_type={['Bank', 'Cash']}
|
||||||
|
readOnly={isWithdrawal}
|
||||||
|
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='pb-2'>
|
||||||
|
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
|
||||||
|
</div>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<AccountFormField
|
||||||
|
name="paid_to"
|
||||||
|
label={_("Paid To")}
|
||||||
|
account_type={['Bank', 'Cash']}
|
||||||
|
isRequired
|
||||||
|
readOnly={!isWithdrawal}
|
||||||
|
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
|
||||||
|
|
||||||
|
<SmallTextField
|
||||||
|
name='remarks'
|
||||||
|
label={_("Custom Remarks")}
|
||||||
|
formDescription={_("This will be auto-populated if not set.")}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<Label>{_("Attachments")}</Label>
|
||||||
|
<FileDropzone files={files} setFiles={setFiles} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 <div className='grid grid-cols-4 gap-4'>
|
||||||
|
{banks.map((bank) => (
|
||||||
|
<button
|
||||||
|
className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||||
|
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||||
|
)}
|
||||||
|
type='button'
|
||||||
|
key={bank.account}
|
||||||
|
onClick={() => onAccountChange(bank.account ?? '')}
|
||||||
|
>
|
||||||
|
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
|
||||||
|
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <button className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||||
|
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||||
|
)}
|
||||||
|
type='button'
|
||||||
|
onClick={() => setSelectedAccount(account ?? '')}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-center h-10 w-10'>
|
||||||
|
<Banknote size='24px' />
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<span className='font-semibold text-sm'>Cash</span>
|
||||||
|
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
|
||||||
|
|
||||||
|
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
|
||||||
|
|
||||||
|
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 (<div className='pb-2'>
|
||||||
|
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
|
||||||
|
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
|
||||||
|
<div>
|
||||||
|
<div className='flex flex-col gap-3'>
|
||||||
|
<div className={cn("flex items-center gap-2 shrink-0",
|
||||||
|
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
|
||||||
|
)}>
|
||||||
|
<BadgeCheck className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
|
||||||
|
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-1.5'>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Calendar size='16px' />
|
||||||
|
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
||||||
|
</div>
|
||||||
|
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-1'>
|
||||||
|
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
|
||||||
|
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
|
||||||
|
)}>
|
||||||
|
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||||
|
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
||||||
|
<div className='pt-1'>
|
||||||
|
<Button
|
||||||
|
onClick={selectTransaction}
|
||||||
|
theme={isSuggested ? "green" : "violet"}
|
||||||
|
size="md"
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
{isSuggested ? <CheckCircle /> : <CheckIcon />}
|
||||||
|
{isSuggested ? _("Accepted") : _("Use Suggestion")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TransferModalContent
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import CSVRawDataPreview from './CSVRawDataPreview'
|
import CSVRawDataPreview from './CSVRawDataPreview'
|
||||||
import StatementDetails from './StatementDetails'
|
import StatementDetails from './StatementDetails'
|
||||||
import _ from '@/lib/translate'
|
|
||||||
import { GetStatementDetailsResponse } from '../import_utils'
|
import { GetStatementDetailsResponse } from '../import_utils'
|
||||||
|
|
||||||
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys'
|
|||||||
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
|
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import _ from '@/lib/translate'
|
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 = [
|
const Shortcuts = [
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ const Shortcuts = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
shortcut: <KbdGroup><Kbd><OptionIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||||
action: {
|
action: {
|
||||||
icon: <ZapIcon />,
|
icon: <ZapIcon />,
|
||||||
label: _("Accept Matching Rule"),
|
label: _("Accept Matching Rule"),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const Preferences = () => {
|
|||||||
|
|
||||||
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
|
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
|
||||||
|
|
||||||
const onUpdate = (field: keyof AccountsSettings, value: any) => {
|
const onUpdate = <K extends keyof AccountsSettings>(field: K, value: AccountsSettings[K]) => {
|
||||||
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
|
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
|
||||||
[field]: value
|
[field]: value
|
||||||
}), {
|
}), {
|
||||||
|
|||||||
@@ -1,95 +1,42 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
import { SettingsIcon } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Preferences } from './Preferences'
|
|
||||||
import MatchingRules from './MatchingRules'
|
|
||||||
import KeyboardShortcuts from './KeyboardShortcuts'
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import SettingsDialogContent from './SettingsDialogContent'
|
||||||
|
|
||||||
const Settings = () => {
|
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', () => {
|
return (
|
||||||
setIsOpen(x => !x)
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
}, {
|
<Tooltip>
|
||||||
enabled: true,
|
<TooltipTrigger asChild>
|
||||||
preventDefault: true,
|
<DialogTrigger asChild>
|
||||||
enableOnFormTags: false
|
<Button variant={'outline'} isIconButton size='md' aria-label={_("Settings")}>
|
||||||
})
|
<SettingsIcon />
|
||||||
|
</Button>
|
||||||
return (
|
</DialogTrigger>
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
</TooltipTrigger>
|
||||||
<Tooltip>
|
<TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
{_("Settings")}
|
||||||
<DialogTrigger asChild>
|
</TooltipContent>
|
||||||
<Button variant={'outline'} isIconButton size='md'>
|
</Tooltip>
|
||||||
<SettingsIcon />
|
{isOpen && (
|
||||||
</Button>
|
<SettingsDialogContent onClose={() => setIsOpen(false)} />
|
||||||
</DialogTrigger>
|
)}
|
||||||
</TooltipTrigger>
|
</Dialog>
|
||||||
<TooltipContent>
|
)
|
||||||
{_("Settings")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<SettingsDialog defaultValue="preferences" onClose={() => setIsOpen(false)}>
|
|
||||||
<SettingsTabs>
|
|
||||||
<SettingsTabGroup header={_("Settings")}>
|
|
||||||
<SettingsTabItem
|
|
||||||
icon={<SlidersVerticalIcon />}
|
|
||||||
label={_("Preferences")}
|
|
||||||
value="preferences"
|
|
||||||
/>
|
|
||||||
<SettingsTabItem
|
|
||||||
icon={<ZapIcon />}
|
|
||||||
label={_("Matching Rules")}
|
|
||||||
value="rules"
|
|
||||||
/>
|
|
||||||
{/* <SettingsTabItem
|
|
||||||
icon={<LandmarkIcon />}
|
|
||||||
label={_("Bank Accounts")}
|
|
||||||
value="bank-accounts"
|
|
||||||
/>
|
|
||||||
<SettingsTabItem
|
|
||||||
icon={<ListIcon />}
|
|
||||||
label={_("Masters")}
|
|
||||||
value="masters"
|
|
||||||
/> */}
|
|
||||||
<SettingsTabItem
|
|
||||||
icon={<KeyboardIcon />}
|
|
||||||
label={_("Keyboard Shortcuts")}
|
|
||||||
value="keyboard-shortcuts"
|
|
||||||
/>
|
|
||||||
</SettingsTabGroup>
|
|
||||||
</SettingsTabs>
|
|
||||||
|
|
||||||
<SettingsPanels>
|
|
||||||
<SettingsPanel value="preferences">
|
|
||||||
<Preferences />
|
|
||||||
</SettingsPanel>
|
|
||||||
<SettingsPanel value="rules">
|
|
||||||
<MatchingRules />
|
|
||||||
</SettingsPanel>
|
|
||||||
<SettingsPanel value="bank-accounts" />
|
|
||||||
<SettingsPanel value="masters" />
|
|
||||||
<SettingsPanel value="keyboard-shortcuts">
|
|
||||||
<KeyboardShortcuts />
|
|
||||||
</SettingsPanel>
|
|
||||||
</SettingsPanels>
|
|
||||||
</SettingsDialog>
|
|
||||||
</Dialog >
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Settings
|
export default Settings
|
||||||
|
|||||||
@@ -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 = () => (
|
||||||
|
<div className="flex flex-1 items-center justify-center min-h-full">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
|
||||||
|
return (
|
||||||
|
<SettingsDialog defaultValue="preferences" onClose={onClose}>
|
||||||
|
<SettingsTabs>
|
||||||
|
<SettingsTabGroup header={_("Settings")}>
|
||||||
|
<SettingsTabItem
|
||||||
|
icon={<SlidersVerticalIcon />}
|
||||||
|
label={_("Preferences")}
|
||||||
|
value="preferences"
|
||||||
|
/>
|
||||||
|
<SettingsTabItem
|
||||||
|
icon={<ZapIcon />}
|
||||||
|
label={_("Matching Rules")}
|
||||||
|
value="rules"
|
||||||
|
/>
|
||||||
|
<SettingsTabItem
|
||||||
|
icon={<KeyboardIcon />}
|
||||||
|
label={_("Keyboard Shortcuts")}
|
||||||
|
value="keyboard-shortcuts"
|
||||||
|
/>
|
||||||
|
</SettingsTabGroup>
|
||||||
|
</SettingsTabs>
|
||||||
|
|
||||||
|
<SettingsPanels>
|
||||||
|
<Suspense fallback={<SettingsPanelsFallback />}>
|
||||||
|
<SettingsPanelsContent />
|
||||||
|
</Suspense>
|
||||||
|
</SettingsPanels>
|
||||||
|
</SettingsDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsDialogContent
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SettingsPanel value="preferences">
|
||||||
|
<Preferences />
|
||||||
|
</SettingsPanel>
|
||||||
|
<SettingsPanel value="rules">
|
||||||
|
<MatchingRules />
|
||||||
|
</SettingsPanel>
|
||||||
|
<SettingsPanel value="bank-accounts" />
|
||||||
|
<SettingsPanel value="masters" />
|
||||||
|
<SettingsPanel value="keyboard-shortcuts">
|
||||||
|
<KeyboardShortcuts />
|
||||||
|
</SettingsPanel>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPanelsContent
|
||||||
@@ -170,7 +170,7 @@ function AlertDialogCancel({
|
|||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
||||||
return (
|
return (
|
||||||
<Button variant={variant} size={size} asChild>
|
<Button variant={variant} size={size} theme={theme} asChild>
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
data-slot="alert-dialog-cancel"
|
data-slot="alert-dialog-cancel"
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface ParsedErrorMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parseHeading = (message?: ParsedErrorMessage) => {
|
const parseHeading = (message?: ParsedErrorMessage) => {
|
||||||
if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
|
if (message?.title === 'Message' || message?.title === 'Error') return _("There was an error.")
|
||||||
return message?.title
|
return message?.title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
banking/src/components/ui/modal-content-fallback.tsx
Normal file
7
banking/src/components/ui/modal-content-fallback.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
export const ModalContentFallback = () => (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -151,7 +151,7 @@ function SettingsTabItem({
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
"flex-1 shrink-0 truncate text-sm leading-4 duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
||||||
icon && "ms-2"
|
icon && "ms-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
37
banking/src/hooks/useMultiFileUploadProgress.ts
Normal file
37
banking/src/hooks/useMultiFileUploadProgress.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react"
|
||||||
|
|
||||||
|
/** Tracks per-file upload progress (0–1) and exposes their average. */
|
||||||
|
export function useMultiFileUploadProgress() {
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0)
|
||||||
|
const fileProgressesRef = useRef<number[]>([])
|
||||||
|
|
||||||
|
const startTracking = useCallback((fileCount: number) => {
|
||||||
|
if (fileCount <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileProgressesRef.current = new Array(fileCount).fill(0)
|
||||||
|
setUploadProgress(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateFileProgress = useCallback((fileIndex: number, progress: number) => {
|
||||||
|
if (fileIndex < 0 || fileIndex >= fileProgressesRef.current.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileProgressesRef.current.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileProgressesRef.current[fileIndex] = progress
|
||||||
|
const total =
|
||||||
|
fileProgressesRef.current.reduce((sum, p) => sum + p, 0) /
|
||||||
|
fileProgressesRef.current.length
|
||||||
|
setUploadProgress(total)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetProgress = useCallback(() => {
|
||||||
|
fileProgressesRef.current = []
|
||||||
|
setUploadProgress(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { uploadProgress, startTracking, updateFileProgress, resetProgress }
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { in_list } from "./checks";
|
|
||||||
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
|
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
|
||||||
import { getSystemDefault } from "./frappe";
|
import { getSystemDefault } from "./frappe";
|
||||||
import _ from "@/lib/translate";
|
import _ from "@/lib/translate";
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||||
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
|
|
||||||
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
||||||
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
|
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
|
||||||
import BankReconciliationStatement from "@/components/features/BankReconciliation/BankReconciliationStatement"
|
|
||||||
import BankTransactions from "@/components/features/BankReconciliation/BankTransactionList"
|
|
||||||
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
|
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
|
||||||
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
|
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
|
||||||
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
|
|
||||||
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
|
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
|
||||||
import Settings from "@/components/features/Settings/Settings"
|
import Settings from "@/components/features/Settings/Settings"
|
||||||
import ActionLog from "@/components/features/ActionLog/ActionLog"
|
import ActionLog from "@/components/features/ActionLog/ActionLog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
import { useLayoutEffect, useRef, useState } from "react"
|
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
|
||||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
@@ -22,6 +18,10 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { useAtomValue } from "jotai"
|
import { useAtomValue } from "jotai"
|
||||||
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
|
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
|
||||||
|
|
||||||
|
const BankReconciliationStatement = lazy(() => import('@/components/features/BankReconciliation/BankReconciliationStatement'))
|
||||||
|
const BankTransactions = lazy(() => import('@/components/features/BankReconciliation/BankTransactionList'))
|
||||||
|
const BankClearanceSummary = lazy(() => import('@/components/features/BankReconciliation/BankClearanceSummary'))
|
||||||
|
const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankReconciliation/IncorrectlyClearedEntries'))
|
||||||
|
|
||||||
const BankReconciliation = () => {
|
const BankReconciliation = () => {
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const BankReconciliation = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
|
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -122,18 +122,24 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
|
|||||||
<TabsContent value="Match and Reconcile">
|
<TabsContent value="Match and Reconcile">
|
||||||
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="Bank Reconciliation Statement">
|
<Suspense fallback={
|
||||||
<BankReconciliationStatement />
|
<div className="flex items-center justify-center p-16">
|
||||||
</TabsContent>
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
<TabsContent value="Bank Transactions">
|
</div>
|
||||||
<BankTransactions />
|
}>
|
||||||
</TabsContent>
|
<TabsContent value="Bank Reconciliation Statement">
|
||||||
<TabsContent value="Bank Clearance Summary">
|
<BankReconciliationStatement />
|
||||||
<BankClearanceSummary />
|
</TabsContent>
|
||||||
</TabsContent>
|
<TabsContent value="Bank Transactions">
|
||||||
<TabsContent value="Incorrectly Cleared Entries">
|
<BankTransactions />
|
||||||
<IncorrectlyClearedEntries />
|
</TabsContent>
|
||||||
</TabsContent>
|
<TabsContent value="Bank Clearance Summary">
|
||||||
|
<BankClearanceSummary />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="Incorrectly Cleared Entries">
|
||||||
|
<IncorrectlyClearedEntries />
|
||||||
|
</TabsContent>
|
||||||
|
</Suspense>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Suspense } from 'react'
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { HomeIcon } from 'lucide-react'
|
import { HomeIcon, Loader2Icon } from 'lucide-react'
|
||||||
import { Link, Outlet } from 'react-router'
|
import { Link, Outlet } from 'react-router'
|
||||||
|
|
||||||
const BankStatementImporterContainer = () => {
|
const BankStatementImporterContainer = () => {
|
||||||
@@ -29,7 +30,13 @@ const BankStatementImporterContainer = () => {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
<Outlet />
|
<Suspense fallback={
|
||||||
|
<div className="flex flex-1 items-center justify-center p-16">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import CSVImport from '@/components/features/BankStatementImporter/CSV/CSVImport'
|
import { lazy } from 'react'
|
||||||
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
|
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useDirection } from '@/components/ui/direction'
|
import { useDirection } from '@/components/ui/direction'
|
||||||
@@ -8,6 +8,8 @@ import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||||
import { Link, useParams } from 'react-router'
|
import { Link, useParams } from 'react-router'
|
||||||
|
|
||||||
|
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
||||||
|
|
||||||
const ViewBankStatementImportLog = () => {
|
const ViewBankStatementImportLog = () => {
|
||||||
|
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BankStatementImportLogColumnMap } from './BankStatementImportLogColumnMap'
|
import { BankStatementImportLogColumnMap } from './BankStatementImportLogColumnMap'
|
||||||
|
|
||||||
export interface BankStatementImportLog{
|
export interface BankStatementImportLog {
|
||||||
name: string
|
name: string
|
||||||
creation: string
|
creation: string
|
||||||
modified: string
|
modified: string
|
||||||
@@ -38,7 +38,7 @@ export interface BankStatementImportLog{
|
|||||||
/** Detected Date Format : Data */
|
/** Detected Date Format : Data */
|
||||||
detected_date_format?: string
|
detected_date_format?: string
|
||||||
/** Detected Amount Format : Select */
|
/** Detected Amount Format : Select */
|
||||||
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has "CR"/"DR" values" | "Amount column has positive/negative values" | "Transaction type column has "CR"/"DR" values" | "Transaction type column has "Deposit"/"Withdrawal" values" | "Transaction type column has "C"/"D" values"
|
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has \"CR\"/\"DR\" values" | "Amount column has positive/negative values" | "Transaction type column has \"CR\"/\"DR\" values" | "Transaction type column has \"Deposit\"/\"Withdrawal\" values" | "Transaction type column has \"C\"/\"D\" values"
|
||||||
/** Detected Header Index : Int */
|
/** Detected Header Index : Int */
|
||||||
detected_header_index?: number
|
detected_header_index?: number
|
||||||
/** Detected Transaction Starting Index : Int */
|
/** Detected Transaction Starting Index : Int */
|
||||||
|
|||||||
@@ -21,5 +21,35 @@ export default defineConfig({
|
|||||||
outDir: '../erpnext/public/banking',
|
outDir: '../erpnext/public/banking',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
target: 'es2015',
|
target: 'es2015',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (id.includes('react-dom') || id.includes('/react/')) {
|
||||||
|
return 'vendor-react'
|
||||||
|
}
|
||||||
|
if (id.includes('frappe-react-sdk')) {
|
||||||
|
return 'vendor-frappe'
|
||||||
|
}
|
||||||
|
if (id.includes('@tanstack')) {
|
||||||
|
return 'vendor-tanstack'
|
||||||
|
}
|
||||||
|
if (id.includes('fuse.js')) {
|
||||||
|
return 'vendor-fuse'
|
||||||
|
}
|
||||||
|
if (id.includes('radix-ui') || id.includes('@radix-ui')) {
|
||||||
|
return 'vendor-radix'
|
||||||
|
}
|
||||||
|
if (id.includes('jotai')) {
|
||||||
|
return 'vendor-jotai'
|
||||||
|
}
|
||||||
|
if (id.includes('lucide-react')) {
|
||||||
|
return 'vendor-lucide'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3333,11 +3333,6 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
|||||||
get-nonce "^1.0.0"
|
get-nonce "^1.0.0"
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
react-virtuoso@^4.18.6:
|
|
||||||
version "4.18.6"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.6.tgz#953637adf805d562892270aafdeeedb0bda1881b"
|
|
||||||
integrity sha512-CrT3P6HyjJMHZVWSste2bG2q5aWGlHfW2QuySZjiFwB2Qok/xsvgy+k8Z2jeDP8PP5KsBip7zNrl/F0QoxeyKw==
|
|
||||||
|
|
||||||
react@^19.2.6:
|
react@^19.2.6:
|
||||||
version "19.2.6"
|
version "19.2.6"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"
|
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"
|
||||||
|
|||||||
Reference in New Issue
Block a user