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"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
onlyExportComponents: false,
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
const common_site_config = require('../../../sites/common_site_config.json');
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const common_site_config = JSON.parse(
|
||||
readFileSync(new URL('../../../sites/common_site_config.json', import.meta.url), 'utf8')
|
||||
) as { webserver_port: string | number };
|
||||
const { webserver_port } = common_site_config;
|
||||
|
||||
export default {
|
||||
'^/(app|api|assets|files|private)': {
|
||||
target: `http://127.0.0.1:${webserver_port}`,
|
||||
ws: true,
|
||||
router: function(req) {
|
||||
const site_name = req.headers.host.split(':')[0];
|
||||
return `http://${site_name}:${webserver_port}`;
|
||||
router: function (req) {
|
||||
const site_name = req.headers?.host?.split(':')[0];
|
||||
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect } from 'react'
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
|
||||
import { TooltipProvider } from './components/ui/tooltip'
|
||||
import BankStatementImporter from '@/pages/BankStatementImporter'
|
||||
import { LucideProvider } from 'lucide-react'
|
||||
import { ThemeProvider } from './components/ui/theme-provider'
|
||||
import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog'
|
||||
import BankStatementImporterContainer from './pages/BankStatementImporterContainer'
|
||||
|
||||
const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter'))
|
||||
const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog'))
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
@@ -43,7 +44,6 @@ function App() {
|
||||
>
|
||||
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
|
||||
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
|
||||
|
||||
<Routes>
|
||||
<Route index element={<BankReconciliation />} />
|
||||
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
||||
|
||||
@@ -1,475 +1,42 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
||||
import { HistoryIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import dayjs from 'dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { slug } from '@/lib/frappe'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { JournalEntry } from '@/types/Accounts/JournalEntry'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
import ActionLogDialog from './ActionLogDialog'
|
||||
|
||||
const ActionLog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<HistoryIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<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>
|
||||
)
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<HistoryIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Reconciliation History")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<ActionLogDialog onClose={() => setIsOpen(false)} />
|
||||
)}
|
||||
</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"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard, _],
|
||||
[copyToClipboard],
|
||||
)
|
||||
|
||||
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">
|
||||
|
||||
@@ -1,831 +1,32 @@
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
|
||||
import { useAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
|
||||
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
|
||||
import _ from "@/lib/translate"
|
||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
||||
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
|
||||
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import FileUploadBanner from "@/components/common/FileUploadBanner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { useGetAccounts } from "@/components/common/AccountsDropdown"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { lazy, Suspense } from "react"
|
||||
|
||||
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
|
||||
|
||||
const BankEntryModal = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RecordBankEntryModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<RecordBankEntryModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard, _],
|
||||
[copyToClipboard],
|
||||
)
|
||||
|
||||
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||
@@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => {
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
],
|
||||
[_, onCopy],
|
||||
[onCopy],
|
||||
)
|
||||
|
||||
const statementRows = useMemo(() => {
|
||||
|
||||
@@ -176,7 +176,7 @@ const BankTransactionListView = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[_, accountCurrency, onUndo],
|
||||
[accountCurrency, onUndo],
|
||||
)
|
||||
|
||||
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 { useAtom, useAtomValue } from "jotai"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useMemo } from "react"
|
||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useAtom } from "jotai"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
|
||||
import _ from "@/lib/translate"
|
||||
|
||||
const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody'))
|
||||
|
||||
const BankTransactionUnreconcileModalFallback = () => (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const BankTransactionUnreconcileModal = () => {
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
|
||||
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogContent className="min-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{_("Are you sure you want to unreconcile this transaction?")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<BankTransactionUnreconcileModalContent />
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
|
||||
if (!unreconcileModal) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="min-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{_("Are you sure you want to unreconcile this transaction?")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
|
||||
<BankTransactionUnreconcileModalBody />
|
||||
</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(
|
||||
@@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[_, accountCurrency, onClearClick],
|
||||
[accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
|
||||
import { Button } from "@/components/ui/button"
|
||||
import CurrencyInput from 'react-currency-input-field'
|
||||
import { getCurrencySymbol } from "@/lib/currency"
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
@@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import _ from "@/lib/translate"
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import TransferModal from "./TransferModal"
|
||||
import BankEntryModal from "./BankEntryModal"
|
||||
import RecordPaymentModal from "./RecordPaymentModal"
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import MatchFilters from "./MatchFilters"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
@@ -69,6 +69,59 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
</>
|
||||
}
|
||||
|
||||
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
|
||||
function VirtualizedListBody<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 bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -134,6 +187,7 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
}
|
||||
|
||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||
const listHeight = contentHeight - 72
|
||||
|
||||
if (isLoading) {
|
||||
return <UnreconciledTransactionsLoadingState />
|
||||
@@ -222,14 +276,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
|
||||
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
|
||||
|
||||
<Virtuoso
|
||||
data={results}
|
||||
itemContent={(_index, transaction) => (
|
||||
<UnreconciledTransactionItem transaction={transaction} />
|
||||
)}
|
||||
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
|
||||
totalCount={results?.length}
|
||||
/>
|
||||
<VirtualizedListBody
|
||||
items={results}
|
||||
height={listHeight}
|
||||
estimateSize={74}
|
||||
getItemKey={(transaction) => transaction.name}
|
||||
>
|
||||
{(transaction) => <UnreconciledTransactionItem transaction={transaction} />}
|
||||
</VirtualizedListBody>
|
||||
|
||||
</div>
|
||||
}
|
||||
@@ -559,11 +613,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
||||
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
if (!rule) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getActionIcon = () => {
|
||||
if (!rule) return null
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return <Landmark />
|
||||
@@ -577,6 +628,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const getActionStyles = () => {
|
||||
if (!rule) return {}
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return {
|
||||
@@ -610,6 +662,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const handleActionClick = () => {
|
||||
if (!rule) return
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
setRecordJournalEntryModalOpen(true)
|
||||
@@ -624,6 +677,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const getActionDescription = () => {
|
||||
if (!rule) return ""
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return _("Create a journal entry for expenses, income or split transactions")
|
||||
@@ -636,8 +690,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys('meta+r', () => {
|
||||
//
|
||||
useHotkeys('alt+r', () => {
|
||||
handleActionClick()
|
||||
}, {
|
||||
enabled: true,
|
||||
@@ -647,6 +700,10 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
|
||||
const styles = getActionStyles()
|
||||
|
||||
if (!rule) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
||||
<CardHeader className="pb-0">
|
||||
@@ -721,6 +778,9 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
|
||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||
|
||||
const voucherList = vouchers?.message ?? []
|
||||
const listHeight = contentHeight - 120
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
@@ -747,7 +807,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
{vouchers?.message.length === 0 && <Empty className="my-4">
|
||||
{voucherList.length === 0 && <Empty className="my-4">
|
||||
<EmptyMedia>
|
||||
<ReceiptIcon />
|
||||
</EmptyMedia>
|
||||
@@ -756,14 +816,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
<Virtuoso
|
||||
data={vouchers?.message}
|
||||
itemContent={(index, voucher) => (
|
||||
<VoucherItem voucher={voucher} index={index} />
|
||||
)}
|
||||
style={{ height: contentHeight }}
|
||||
totalCount={vouchers?.message.length}
|
||||
/>
|
||||
<VirtualizedListBody
|
||||
items={voucherList}
|
||||
height={listHeight}
|
||||
estimateSize={121}
|
||||
getItemKey={(voucher) => voucher.name}
|
||||
>
|
||||
{(voucher, index) => <VoucherItem voucher={voucher} index={index} />}
|
||||
</VirtualizedListBody>
|
||||
</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 { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
|
||||
import _ from '@/lib/translate'
|
||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { useForm, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { H4 } from '@/components/ui/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Form } from '@/components/ui/form'
|
||||
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
|
||||
import SelectedTransactionsTable from './SelectedTransactionsTable'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { useContext, useMemo, useState } from 'react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone'
|
||||
import FileUploadBanner from '@/components/common/FileUploadBanner'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { bankRecTransferModalAtom } from './bankRecAtoms'
|
||||
|
||||
const TransferModalContent = lazy(() => import('./TransferModalContent'))
|
||||
|
||||
const TransferModal = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TransferModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<TransferModalContent />
|
||||
</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 StatementDetails from './StatementDetails'
|
||||
import _ from '@/lib/translate'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import _ from '@/lib/translate'
|
||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||
|
||||
const Shortcuts = [
|
||||
{
|
||||
@@ -32,7 +32,7 @@ const Shortcuts = [
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||
shortcut: <KbdGroup><Kbd><OptionIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <ZapIcon />,
|
||||
label: _("Accept Matching Rule"),
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Preferences = () => {
|
||||
|
||||
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", {
|
||||
[field]: value
|
||||
}), {
|
||||
|
||||
@@ -1,95 +1,42 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
SettingsDialog,
|
||||
SettingsPanel,
|
||||
SettingsPanels,
|
||||
SettingsTabGroup,
|
||||
SettingsTabItem,
|
||||
SettingsTabs,
|
||||
} from '@/components/ui/settings-dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Preferences } from './Preferences'
|
||||
import MatchingRules from './MatchingRules'
|
||||
import KeyboardShortcuts from './KeyboardShortcuts'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import SettingsDialogContent from './SettingsDialogContent'
|
||||
|
||||
const Settings = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
useHotkeys('shift+meta+g', () => {
|
||||
setIsOpen(x => !x)
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: false
|
||||
})
|
||||
|
||||
useHotkeys('shift+meta+g', () => {
|
||||
setIsOpen(x => !x)
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: false
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<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 >
|
||||
)
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md' aria-label={_("Settings")}>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Settings")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<SettingsDialogContent onClose={() => setIsOpen(false)} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<Button variant={variant} size={size} theme={theme} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface 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
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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"
|
||||
)}
|
||||
>
|
||||
|
||||
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 { getSystemDefault } from "./frappe";
|
||||
import _ from "@/lib/translate";
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
|
||||
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
||||
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 CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
|
||||
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
|
||||
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
|
||||
import Settings from "@/components/features/Settings/Settings"
|
||||
import ActionLog from "@/components/features/ActionLog/ActionLog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import _ from "@/lib/translate"
|
||||
import { useLayoutEffect, useRef, useState } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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 { 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 = () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ const BankReconciliation = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -122,18 +122,24 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
|
||||
<TabsContent value="Match and Reconcile">
|
||||
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
</Tabs>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
|
||||
import _ from '@/lib/translate'
|
||||
import { HomeIcon } from 'lucide-react'
|
||||
import { HomeIcon, Loader2Icon } from 'lucide-react'
|
||||
import { Link, Outlet } from 'react-router'
|
||||
|
||||
const BankStatementImporterContainer = () => {
|
||||
@@ -29,7 +30,13 @@ const BankStatementImporterContainer = () => {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { Button } from '@/components/ui/button'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
@@ -8,6 +8,8 @@ import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { Link, useParams } from 'react-router'
|
||||
|
||||
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
||||
|
||||
const ViewBankStatementImportLog = () => {
|
||||
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BankStatementImportLogColumnMap } from './BankStatementImportLogColumnMap'
|
||||
|
||||
export interface BankStatementImportLog{
|
||||
export interface BankStatementImportLog {
|
||||
name: string
|
||||
creation: string
|
||||
modified: string
|
||||
@@ -38,7 +38,7 @@ export interface BankStatementImportLog{
|
||||
/** Detected Date Format : Data */
|
||||
detected_date_format?: string
|
||||
/** 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?: number
|
||||
/** Detected Transaction Starting Index : Int */
|
||||
|
||||
@@ -21,5 +21,35 @@ export default defineConfig({
|
||||
outDir: '../erpnext/public/banking',
|
||||
emptyOutDir: true,
|
||||
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"
|
||||
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:
|
||||
version "19.2.6"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"
|
||||
|
||||
Reference in New Issue
Block a user