mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-14 02:31:21 +00:00
feat: new banking module (#54720)
* feat: initial SPA setup for banking * wip: bring over new banking module * feat: added Espresso design tokens * feat: button styles * fix: add all ink colors * wip: espresso design system changes * feat: button and badge espresso components * fix: button styling for reconcile * feat: Espresso progress bar * feat: Espresso toggle switch * feat: Espresso tabs design * fix: vertical tab support * fix: button sizing across modals * feat: Espresso style table layout * feat: Espresso tooltip * feat: Espresso elevations and checkbox * feat: Dialog with Espresso styles * feat: Espresso textarea * fix: input styles * fix: colors on bank picker * fix: breadcrumb styling * fix: bank picker styling * feat: create doctypes and fields for bank reconciliation * feat: APIs for banking * fix: use date format parser * fix: font styling to match Espresso * wip: settings modal * feat: settings dialog component * fix: icons and invalid requests * feat: preferences tab * fix: adjust icon stroke width to 1.5 * feat: rule configuration in settings * fix: remove sheet component * feat: alert and error banner component * feat: dropdown in Espresso * feat: popover and select in Espresso * fix: cleanup more styles * fix: match size of link fields * feat: command styling * fix: remove unused style tokens * fix: styles for global date picker dropdown * fix: styles for match and reconcile * feat: table Espresso component * feat: remove all other design tokens * fix: remove unused tokens * fix: form elements * fix: remove unused styles and fix filters in bank transaction list * feat: fetch bank rec doctypes for filtering * fix: record payment modal * feat: support for dark mode switching * fix: move bank logos to public folder * feat: add support for RTL * feat: support for RTL * chore: send layout direction in dev boot * fix: make checkbox work in RTL * feat: dark mode support * fix: dark mode style * feat: bank logos in dark mode * feat: dark mode bank logos * chore: use dark mode bank logos everywhere * chore: move rule evaluation to controller * chore: add tests for bank transaction rules * fix: move deps to fix actions errors * fix: move tw-animate-css to deps * fix: remove shadcn * fix: do not open modal if no transactions selected * fix: add translation strings * feat: add banner on existing bank reconciliation tool * feat: bank statement import * fix: translations and layout directions * fix: validation for transaction matching rule * fix: styles * fix: show conflicting transactions in alert * fix: show help text for new banking module forms * feat: show total debits and credits * fix: dark mode colors in automatic config * feat: add keyboard shortcuts help * feat: added keyboard shortcut for settings * fix: decrease size of progress bar * chore: bump packages * feat: add tests for statement import * fix: settings dialog * fix: show banner on small screens * fix: show banner when no bank account set
This commit is contained in:
475
banking/src/components/features/ActionLog/ActionLog.tsx
Normal file
475
banking/src/components/features/ActionLog/ActionLog.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import dayjs from 'dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { slug } from '@/lib/frappe'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { JournalEntry } from '@/types/Accounts/JournalEntry'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
const ActionLog = () => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecClosingBalanceAtom, bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetDocCount, useFrappeGetDocList, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
|
||||
import { Edit, Info, Trash2 } from "lucide-react"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import _ from "@/lib/translate"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { CurrencyFormField } from "@/components/ui/form-elements"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContext, useState } from "react"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { BankAccountBalance } from "@/types/Accounts/BankAccountBalance"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
|
||||
const BankBalance = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="w-[80%] flex flex-wrap justify-between gap-2 pe-8 border-e-border border-e">
|
||||
<OpeningBalance />
|
||||
<ClosingBalance />
|
||||
<ClosingBalanceAsPerStatement />
|
||||
<Difference />
|
||||
</div>
|
||||
|
||||
<ReconcileProgress />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OpeningBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const { data, isLoading } = useGetAccountOpeningBalance()
|
||||
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Opening Balance")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
}
|
||||
|
||||
const ClosingBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
return (
|
||||
<StatContainer className="min-w-48">
|
||||
<div className="flex items-start gap-1">
|
||||
<StatLabel>
|
||||
{_("Closing Balance as per system")}
|
||||
</StatLabel>
|
||||
<HoverCard openDelay={100}>
|
||||
<HoverCardTrigger>
|
||||
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96" align="start" side="right">
|
||||
<H4 className="text-base">{_("Closing balance as per system")}</H4>
|
||||
<Paragraph className="mt-2 text-p-sm">
|
||||
{_("This is what the system expects the closing balance to be in your bank statement.")}
|
||||
<br />
|
||||
{_("It takes into account all the transactions that have been posted and subtracts the transactions that have not cleared yet.")}
|
||||
<br />
|
||||
{_("If your bank statement shows a different closing balance, it is because all transactions have not reconciled yet.")}
|
||||
<br /><br />
|
||||
For more information, click on the <strong>Bank Reconciliation Statement</strong> tab below.
|
||||
</Paragraph>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</div>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Difference = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
const value = useAtomValue(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const difference = flt(value.value - (data?.message ?? 0))
|
||||
|
||||
const isError = difference !== 0
|
||||
|
||||
return <StatContainer className="w-fit text-end sm:min-w-56">
|
||||
<StatLabel className="text-end">{_("Difference")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
|
||||
{formatCurrency(difference,
|
||||
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
|
||||
}</StatValue>}
|
||||
</StatContainer>
|
||||
}
|
||||
|
||||
const ReconcileProgress = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const { data: totalCount } = useFrappeGetDocCount<BankTransaction>('Bank Transaction', [
|
||||
["bank_account", "=", bankAccount?.name ?? ''],
|
||||
['docstatus', '=', 1],
|
||||
['date', '<=', dates?.toDate],
|
||||
['date', '>=', dates?.fromDate]
|
||||
], false, undefined, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
const { data: unreconciledTransactions, } = useGetUnreconciledTransactions()
|
||||
|
||||
const reconciledCount = (totalCount ?? 0) - (unreconciledTransactions?.message?.length ?? 0)
|
||||
|
||||
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
|
||||
|
||||
return <div className="w-[18%] flex flex-col gap-1 items-end">
|
||||
<div className="w-full">
|
||||
<Progress
|
||||
value={progress}
|
||||
max={100}
|
||||
size="md"
|
||||
label="Progress"
|
||||
hint
|
||||
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ClosingBalanceAsPerStatement = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
|
||||
onSuccess: (data) => {
|
||||
if (data?.message && data?.message?.balance) {
|
||||
setValue({
|
||||
value: data?.message?.balance,
|
||||
stringValue: data?.message?.balance.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isDateSame = data?.message?.date === dates.toDate
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Closing Balance as per statement")}</StatLabel>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
|
||||
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
<Edit className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Click to set the closing balance as per statement")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-xl">
|
||||
<ClosingBalanceForm
|
||||
defaultBalance={data?.message?.balance ?? 0}
|
||||
date={dates.toDate}
|
||||
bankAccount={bankAccount}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{!isDateSame && data?.message.date && <span className="text-xs font-medium text-ink-red-3">{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}</span>}
|
||||
</div>
|
||||
</StatContainer>
|
||||
|
||||
}
|
||||
|
||||
const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const form = useForm<{ balance: number }>({
|
||||
defaultValues: {
|
||||
balance: defaultBalance
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { call, loading, error } = useFrappePostCall("erpnext.accounts.doctype.bank_account.bank_account.set_closing_balance_as_per_statement")
|
||||
|
||||
const onSubmit = (data: { balance: number }) => {
|
||||
if (data.balance) {
|
||||
call({
|
||||
bank_account: bankAccount?.name ?? '',
|
||||
date: date,
|
||||
balance: data.balance
|
||||
})
|
||||
.then(() => {
|
||||
// Mutate the closing balance as per statement
|
||||
mutate(`bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${date}`)
|
||||
setValue({
|
||||
value: data.balance,
|
||||
stringValue: data.balance.toString()
|
||||
})
|
||||
toast.success(_("Closing balance set."))
|
||||
onClose()
|
||||
|
||||
|
||||
})
|
||||
} else {
|
||||
toast.error(_("Closing balance is required."))
|
||||
}
|
||||
}
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Set closing balance as per bank statement")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className="py-4">
|
||||
<CurrencyFormField
|
||||
name="balance"
|
||||
label={_("Closing balance on bank statement as of {0}", [formatDate(date, 'Do MMM YYYY')])}
|
||||
isRequired
|
||||
currency={currency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button type='submit' size='md' disabled={loading}>{_("Save")}</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<ClosingBalancesList bankAccount={bankAccount} date={date} />
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank | null, date: string }) => {
|
||||
|
||||
const { data, mutate } = useFrappeGetDocList<BankAccountBalance>("Bank Account Balance", {
|
||||
filters: [["bank_account", "=", bankAccount?.name ?? ''], ["date", "<=", date]],
|
||||
orderBy: {
|
||||
field: "date",
|
||||
order: "desc"
|
||||
},
|
||||
fields: ["date", "balance", "name"],
|
||||
limit: 10
|
||||
})
|
||||
|
||||
const { db } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const onDelete = (name: string) => {
|
||||
toast.promise(db.deleteDoc("Bank Account Balance", name).then(() => {
|
||||
mutate()
|
||||
}), {
|
||||
loading: _("Deleting closing balance..."),
|
||||
success: _("Closing balance deleted."),
|
||||
error: _("Failed to delete closing balance.")
|
||||
})
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div>
|
||||
<Separator className="my-8" />
|
||||
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Date")}</TableHead>
|
||||
<TableHead className="text-end">{_("Balance")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((item) => (
|
||||
<TableRow key={item.name}>
|
||||
<TableCell>{formatDate(item.date, 'Do MMM YYYY')}</TableCell>
|
||||
<TableCell className="text-end">{formatCurrency(flt(item.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</TableCell>
|
||||
<TableCell className="text-end">
|
||||
<Button
|
||||
title={_("Delete")}
|
||||
type='button' isIconButton variant='ghost' onClick={() => onDelete(item.name)}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
export default BankBalance
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { QueryReportReturnType } from "@/types/custom/Reports"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import { CheckCircle2, ReceiptTextIcon, XCircle } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import _ from "@/lib/translate"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { DateField } from "@/components/ui/form-elements"
|
||||
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
|
||||
|
||||
const BankClearanceSummary = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return <MissingFiltersBanner text={_("Please select a bank account to view the bank clearance summary.")} />
|
||||
}
|
||||
|
||||
if (!dates) {
|
||||
return <MissingFiltersBanner text={_("Please select dates to view the bank clearance summary.")} />
|
||||
}
|
||||
|
||||
return <BankClearanceSummaryView />
|
||||
}
|
||||
interface BankClearanceSummaryEntry {
|
||||
payment_document_type: string
|
||||
payment_entry: string
|
||||
posting_date: string,
|
||||
cheque_no?: string,
|
||||
amount: number,
|
||||
against: string,
|
||||
clearance_date: string,
|
||||
}
|
||||
|
||||
const BankClearanceSummaryView = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
account: bankAccount?.account,
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate
|
||||
})
|
||||
}, [bankAccount, dates])
|
||||
|
||||
const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType<BankClearanceSummaryEntry> }>('frappe.desk.query_report.run', {
|
||||
report_name: 'Bank Clearance Summary',
|
||||
filters,
|
||||
ignore_prepared_report: 1,
|
||||
are_default_filters: false,
|
||||
}, `Report-Bank Clearance Summary-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
|
||||
|
||||
const formattedFromDate = formatDate(dates.fromDate)
|
||||
const formattedToDate = formatDate(dates.toDate)
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const onCopy = useCallback(
|
||||
(text: string) => {
|
||||
copyToClipboard(text).then(() => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard, _],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
() => bankAccount?.account_currency ?? getCompanyCurrency(companyID),
|
||||
[bankAccount?.account_currency, companyID],
|
||||
)
|
||||
|
||||
const clearanceColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "payment_document_type",
|
||||
header: _("Document Type"),
|
||||
size: 140,
|
||||
cell: ({ row }) => _(row.original.payment_document_type),
|
||||
},
|
||||
{
|
||||
id: "payment_entry",
|
||||
header: _("Payment Document"),
|
||||
size: 160,
|
||||
meta: {
|
||||
getTooltipText: (r) => {
|
||||
const x = r as BankClearanceSummaryEntry
|
||||
return [x.payment_document_type, x.payment_entry].filter(Boolean).join(" · ") || undefined
|
||||
},
|
||||
} satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/${slug(row.original.payment_document_type)}/${row.original.payment_entry}`}
|
||||
>
|
||||
{row.original.payment_entry}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "posting_date",
|
||||
header: _("Posting Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.posting_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "cheque_no",
|
||||
header: _("Cheque/Reference Number"),
|
||||
size: 160,
|
||||
cell: ({ row }) => {
|
||||
const ref = row.original.cheque_no ?? ""
|
||||
return (
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-ink-gray-8 hover:underline min-w-0 w-full cursor-pointer truncate text-start underline-offset-4"
|
||||
onClick={() => onCopy(ref)}
|
||||
>
|
||||
{ref}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{ref}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "clearance_date",
|
||||
header: _("Clearance Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "against",
|
||||
header: _("Against Account"),
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: _("Amount"),
|
||||
size: 150,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.amount, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: _("Status"),
|
||||
size: 200,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return r.clearance_date ? (
|
||||
<Badge theme="green">
|
||||
<CheckCircle2 />
|
||||
{_("Cleared")}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge theme="red">
|
||||
<XCircle />
|
||||
{_("Not Cleared")}
|
||||
</Badge>
|
||||
<SetClearanceDateButton
|
||||
voucher={r}
|
||||
bankAccount={bankAccount}
|
||||
companyID={companyID}
|
||||
mutate={mutate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[_, accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||
)
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.result.length > 0 ? (
|
||||
<ListView
|
||||
data={data.message.result}
|
||||
columns={clearanceColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data && data.message.result.length == 0 &&
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
<ReceiptTextIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No entries found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no accounting entries in the system for the selected account and dates.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const SetClearanceDateButton = ({ voucher, bankAccount, companyID, mutate }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank | null, companyID: string, mutate: VoidFunction }) => {
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const onClose = () => {
|
||||
setOpen(false)
|
||||
mutate()
|
||||
}
|
||||
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger disabled={!bankAccount}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger>
|
||||
<Button variant='link' size="sm" className="px-0" theme="red">{_("Force Clear")}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align='start'>
|
||||
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-2xl">
|
||||
{bankAccount && <ForceClearVoucherForm voucher={voucher} bankAccount={bankAccount} companyID={companyID} onClose={onClose} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
const ForceClearVoucherForm = ({ voucher, bankAccount, companyID, onClose }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank, companyID: string, onClose: () => void }) => {
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const form = useForm<{ clearance_date: string }>({
|
||||
defaultValues: {
|
||||
clearance_date: voucher.posting_date,
|
||||
}
|
||||
})
|
||||
|
||||
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.update_clearance_date')
|
||||
|
||||
const onSubmit = (data: { clearance_date: string }) => {
|
||||
call({
|
||||
payment_document: voucher.payment_document_type,
|
||||
payment_entry: voucher.payment_entry,
|
||||
account: bankAccount.account,
|
||||
clearance_date: data.clearance_date,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(_("Clearance date updated"))
|
||||
onClose()
|
||||
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
|
||||
})
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Force Clear Voucher")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Payment Document")}</TableHead>
|
||||
<TableCell><a target="_blank" className="underline underline-offset-4"
|
||||
href={`/desk/${slug(voucher.payment_document_type)}/${voucher.payment_entry}`}>{_(voucher.payment_document_type)} : {voucher.payment_entry}</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Posting Date")}</TableHead>
|
||||
<TableCell>{formatDate(voucher.posting_date)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Cheque/Reference Number")}</TableHead>
|
||||
<TableCell title={voucher.cheque_no}>{voucher.cheque_no?.slice(0, 40)}{voucher.cheque_no?.length && voucher.cheque_no?.length > 40 ? "..." : ""}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Amount")}</TableHead>
|
||||
<TableCell className="text-end">{formatCurrency(voucher.amount, bankAccount?.account_currency ?? getCompanyCurrency(companyID))}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Against Account")}</TableHead>
|
||||
<TableCell><a target="_blank" className="underline underline-offset-4" href={`/desk/account/${voucher.against}`}>{voucher.against}</a></TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
<DateField
|
||||
name='clearance_date'
|
||||
label={_("Clearance Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: true }}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} disabled={loading} size='md'>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button type='submit' disabled={loading} size='md'>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
export default BankClearanceSummary
|
||||
@@ -0,0 +1,831 @@
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
|
||||
import _ from "@/lib/translate"
|
||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
||||
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
|
||||
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import FileUploadBanner from "@/components/common/FileUploadBanner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { useGetAccounts } from "@/components/common/AccountsDropdown"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
const BankEntryModal = () => {
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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,124 @@
|
||||
import { useAtom, useSetAtom } from "jotai"
|
||||
import { SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCallback } from "react"
|
||||
import { useGetBankAccounts, useGetUnreconciledTransactions } from "./utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getTimeago } from "@/lib/date"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTheme } from "@/components/ui/theme-provider"
|
||||
import BankLogo from "@/components/common/BankLogo"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { LandmarkIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
|
||||
const BankPicker = ({ className }: { className?: string }) => {
|
||||
|
||||
const setSelectedBank = useSetAtom(selectedBankAccountAtom)
|
||||
|
||||
const onLoadingSuccess = useCallback((data?: SelectedBank[]) => {
|
||||
if (!data) return
|
||||
if (data.length === 1) {
|
||||
setSelectedBank(data[0])
|
||||
} else if (data.length > 1) {
|
||||
const defaultBank = data.find((bank: SelectedBank) => bank.is_default)
|
||||
if (defaultBank) {
|
||||
setSelectedBank(defaultBank)
|
||||
}
|
||||
}
|
||||
}, [setSelectedBank])
|
||||
|
||||
const selectedCompany = useCurrentCompany()
|
||||
|
||||
const { banks, isLoading, error } = useGetBankAccounts(onLoadingSuccess)
|
||||
|
||||
const { themeValue } = useTheme()
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
|
||||
if (banks?.length === 0) {
|
||||
return <Empty>
|
||||
<EmptyMedia>
|
||||
<LandmarkIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No bank accounts found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("You have not added any bank accounts to your company.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild>
|
||||
<a href={`/desk/bank-account?company=${encodeURIComponent(selectedCompany)}&is_company_account=1`}>
|
||||
{_("Configure Bank Accounts")}
|
||||
</a>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4",
|
||||
banks?.length > 4 ? 'pb-2' : '', className,
|
||||
)}
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: themeValue === 'Dark' ? 'var(--surface-gray-2) var(--surface-gray-1)' : 'rgb(209 213 219) rgb(243 244 246)',
|
||||
}}
|
||||
>
|
||||
{
|
||||
banks?.map((bank) => (
|
||||
<BankPickerItem key={bank.name} bank={bank} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
|
||||
|
||||
const [selectedBank, setSelectedBank] = useAtom(selectedBankAccountAtom)
|
||||
|
||||
const isSelected = selectedBank?.name === bank.name
|
||||
|
||||
const { mutate } = useGetUnreconciledTransactions()
|
||||
|
||||
const onSelect = () => {
|
||||
setSelectedBank(bank)
|
||||
mutate()
|
||||
}
|
||||
|
||||
return <div
|
||||
role="button"
|
||||
title={`Select ${bank.account_name}`}
|
||||
onClick={onSelect}
|
||||
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer',
|
||||
isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1'
|
||||
)}
|
||||
>
|
||||
|
||||
|
||||
<BankLogo bank={bank} className="mb-2" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className={cn("text-sm font-medium line-clamp-1 text-ink-gray-8")}>{bank.account_name}</span>
|
||||
{bank.account_type && <Badge variant='subtle' size='sm' theme='gray'>
|
||||
{bank.account_type?.slice(0, 24)}
|
||||
</Badge>}
|
||||
</div>
|
||||
|
||||
<span title={_("GL Account")} className={cn("text-ellipsis line-clamp-1 text-sm text-ink-gray-6")}>{bank.account}</span>
|
||||
{bank.last_integration_date && <span className="text-xs text-ink-gray-5">{_("Last Synced Transaction")}: {getTimeago(bank.last_integration_date)}</span>}
|
||||
</div>
|
||||
|
||||
</div >
|
||||
}
|
||||
|
||||
export default BankPicker
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { bankRecDateAtom } from './bankRecAtoms'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod } from '@/lib/date'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react'
|
||||
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { parse } from "chrono-node"
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import useFiscalYear from '@/hooks/useFiscalYear'
|
||||
import dayjs from 'dayjs'
|
||||
import _ from '@/lib/translate'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
|
||||
const BankRecDateFilter = () => {
|
||||
|
||||
const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom)
|
||||
|
||||
const { data: fiscalYear } = useFiscalYear()
|
||||
|
||||
const timePeriodOptions = useMemo(() => {
|
||||
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
|
||||
const dates = getDatesForTimePeriod(period)
|
||||
return {
|
||||
label: period,
|
||||
fromDate: dates.fromDate,
|
||||
toDate: dates.toDate,
|
||||
format: dates.format,
|
||||
translatedLabel: dates.translatedLabel
|
||||
}
|
||||
})
|
||||
|
||||
if (fiscalYear?.message) {
|
||||
// For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters
|
||||
const fiscalYearStart = fiscalYear.message.year_start_date
|
||||
const fiscalYearEnd = fiscalYear.message.year_end_date
|
||||
|
||||
const q1 = {
|
||||
label: `Q1: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q2 = {
|
||||
label: `Q2: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q3 = {
|
||||
label: `Q3: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q4 = {
|
||||
label: `Q4: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const thisYear = {
|
||||
label: `This Fiscal Year`,
|
||||
translatedLabel: `${_("This Fiscal Year")}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const lastYear = {
|
||||
label: `Last Fiscal Year`,
|
||||
translatedLabel: `${_("Last Fiscal Year")}`,
|
||||
fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
// Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options
|
||||
|
||||
const topRankedItems = standardOptions.filter((option) => {
|
||||
return option.label === "This Month" || option.label === "Last Month"
|
||||
})
|
||||
|
||||
const bottomRankedItems = standardOptions.filter((option) => {
|
||||
return option.label !== "This Month" && option.label !== "Last Month"
|
||||
})
|
||||
|
||||
return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems]
|
||||
}
|
||||
|
||||
return standardOptions
|
||||
}, [fiscalYear])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState("")
|
||||
|
||||
const timePeriod: TimePeriod | string = useMemo(() => {
|
||||
if (bankRecDate.fromDate && bankRecDate.toDate) {
|
||||
// Check if the from and to dates match any predefined time period
|
||||
for (const period of timePeriodOptions) {
|
||||
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
|
||||
return period.label;
|
||||
}
|
||||
}
|
||||
return "Date Range";
|
||||
} else {
|
||||
return "Date Range";
|
||||
}
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]);
|
||||
|
||||
const handleTimePeriodChange = (fromDate: string, toDate: string) => {
|
||||
setBankRecDate({ fromDate, toDate })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const dateObj = useMemo(() => {
|
||||
return {
|
||||
from: new Date(bankRecDate.fromDate),
|
||||
to: new Date(bankRecDate.toDate)
|
||||
}
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate])
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
|
||||
|
||||
return <div className='flex items-center'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
aria-expanded={open}
|
||||
size='md'
|
||||
className='rounded-e-none border-e-0'
|
||||
role="combobox">
|
||||
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-84 p-1" align='start'>
|
||||
<Command>
|
||||
|
||||
<CommandInput placeholder="e.g. Last 3 weeks" onValueChange={setValue} value={value} />
|
||||
<CommandList className='max-h-fit'>
|
||||
<CommandEmpty className='text-start p-2 hover:bg-surface-gray-1'>
|
||||
<EmptyState onSelect={handleTimePeriodChange} value={value} />
|
||||
</CommandEmpty>
|
||||
{timePeriodOptions.map((period) => (
|
||||
<CommandItem key={period.label} className='flex justify-between' onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
|
||||
<span>
|
||||
{period.translatedLabel ?? _(period.label)}
|
||||
</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{formatDate(period.fromDate, period.format)} {direction === 'ltr' ? <ChevronRight className='text-[12px] text-ink-gray-5/70' /> : <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />} {formatDate(period.toDate, period.format)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={'outline'} className='rounded-s-none' size='md'>
|
||||
{formatDate(bankRecDate.fromDate)} - {formatDate(bankRecDate.toDate)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto overflow-hidden p-0' align='end'>
|
||||
<Calendar
|
||||
mode='range'
|
||||
captionLayout='dropdown'
|
||||
selected={{
|
||||
from: dateObj.from,
|
||||
to: dateObj.to
|
||||
}}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={dateObj.from}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
setBankRecDate({ fromDate: formatDate(date.from, 'YYYY-MM-DD'), toDate: formatDate(date.to, 'YYYY-MM-DD') })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
|
||||
const referentialKeywords = ["last", "this", "next", "previous"]
|
||||
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
|
||||
|
||||
const dates = useMemo(() => {
|
||||
if (value) {
|
||||
// Try parsing the value
|
||||
const parsedDate = parse(value, undefined, { forwardDate: false })
|
||||
|
||||
if (parsedDate && parsedDate.length > 0) {
|
||||
const startDate = parsedDate[0].start.date()
|
||||
const endDate = parsedDate[0].end?.date()
|
||||
|
||||
if (!endDate) {
|
||||
const today = new Date()
|
||||
// If today is greater than the start date, use today as the end date
|
||||
if (startDate.getTime() > today.getTime()) {
|
||||
return { fromDate: today, toDate: startDate }
|
||||
} else {
|
||||
// Check if the user only wants a specific month like "May 2025"
|
||||
// If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) {
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: dayjs(startDate).endOf('month').toDate()
|
||||
}
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
} else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
|
||||
// If month and day is known, then we should not assume that the user wants to get everything until today
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: startDate,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: today
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { fromDate: startDate, toDate: endDate }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const onClick = (fromDate: Date, toDate: Date) => {
|
||||
onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date')
|
||||
|
||||
return <div>
|
||||
{dates ?
|
||||
<div className='flex gap-2 items-center justify-between cursor-pointer' onClick={() => onClick(dates.fromDate, dates.toDate)}>
|
||||
<span className='text-sm text-ink-gray-5 max-w-[30%]'>
|
||||
{value}
|
||||
</span>
|
||||
{isEqual ? <span className='text-xs text-ink-gray-5 text-balance flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YYYY')}
|
||||
</span> :
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YY')} <ChevronRight size='16' className='text-ink-gray-5' /> {formatDate(dates.toDate, 'Do MMM YY')}
|
||||
</span>}
|
||||
</div> :
|
||||
<span className='text-sm text-ink-gray-5'>
|
||||
No results found
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BankRecDateFilter
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { QueryReportReturnType } from "@/types/custom/Reports"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import { ScrollTextIcon } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
|
||||
import _ from "@/lib/translate"
|
||||
import { toast } from "sonner"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
|
||||
const BankReconciliationStatement = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return <MissingFiltersBanner text={_("Please select a bank account to view the bank reconciliation statement.")} />
|
||||
}
|
||||
|
||||
if (!dates) {
|
||||
return <MissingFiltersBanner text={_("Please select dates to view the bank reconciliation statement.")} />
|
||||
}
|
||||
|
||||
return <BankReconciliationStatementView />
|
||||
}
|
||||
interface BankClearanceSummaryEntry {
|
||||
payment_document: string
|
||||
payment_entry: string
|
||||
posting_date: string,
|
||||
reference_no: string,
|
||||
credit: number,
|
||||
debit: number,
|
||||
against_account: string,
|
||||
ref_date: string,
|
||||
account_currency: string,
|
||||
clearance_date: string
|
||||
}
|
||||
|
||||
const BankReconciliationStatementView = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
account: bankAccount?.account,
|
||||
report_date: dates.toDate,
|
||||
company: companyID
|
||||
})
|
||||
}, [bankAccount, dates, companyID])
|
||||
|
||||
const { data, error } = useFrappeGetCall<{ message: QueryReportReturnType }>('frappe.desk.query_report.run', {
|
||||
report_name: 'Bank Reconciliation Statement',
|
||||
filters,
|
||||
ignore_prepared_report: 1,
|
||||
are_default_filters: false,
|
||||
}, `Report-Bank Reconciliation Statement-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const onCopy = useCallback(
|
||||
(text: string) => {
|
||||
copyToClipboard(text).then(() => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard, _],
|
||||
)
|
||||
|
||||
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "posting_date",
|
||||
header: _("Posting Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.posting_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "payment_document",
|
||||
header: _("Document Type"),
|
||||
size: 140,
|
||||
cell: ({ row }) => _(row.original.payment_document),
|
||||
},
|
||||
{
|
||||
id: "payment_entry",
|
||||
header: _("Payment Document"),
|
||||
size: 300,
|
||||
meta: {
|
||||
getTooltipText: (r) => {
|
||||
const x = r as BankClearanceSummaryEntry
|
||||
const parts = [x.payment_document, x.payment_entry].filter(Boolean)
|
||||
return parts.length ? parts.join(" · ") : undefined
|
||||
},
|
||||
} satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => {
|
||||
const { payment_document, payment_entry } = row.original
|
||||
return payment_document ? (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/${slug(payment_document)}/${payment_entry}`}
|
||||
>
|
||||
{payment_entry}
|
||||
</a>
|
||||
) : (
|
||||
payment_entry
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "debit",
|
||||
header: _("Debit"),
|
||||
size: 112,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.debit, row.original.account_currency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "credit",
|
||||
header: _("Credit"),
|
||||
size: 112,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.credit, row.original.account_currency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "against_account",
|
||||
header: _("Against Account"),
|
||||
meta: { gridWidth: "minmax(0,1.25fr)" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/account/${row.original.against_account}`}
|
||||
>
|
||||
{row.original.against_account}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reference_no",
|
||||
header: _("Reference #"),
|
||||
cell: ({ row }) => {
|
||||
const ref = row.original.reference_no
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-ink-gray-8 hover:underline min-w-0 w-full cursor-pointer truncate text-start underline-offset-4"
|
||||
onClick={() => onCopy(ref)}
|
||||
>
|
||||
{ref}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "ref_date",
|
||||
header: _("Reference Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.ref_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "clearance_date",
|
||||
header: _("Clearance Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
],
|
||||
[_, onCopy],
|
||||
)
|
||||
|
||||
const statementRows = useMemo(() => {
|
||||
if (!data?.message.result) return []
|
||||
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
|
||||
}, [data])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && <SummarySection data={data} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
|
||||
<ListView
|
||||
data={statementRows}
|
||||
columns={statementColumns}
|
||||
getRowId={(row) => row.payment_entry}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
emptyState={_("No entries with a payment document in this list.")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.message.result.length === 0 &&
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
<ScrollTextIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No entries found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no accounting entries in the system for the selected account and dates.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const SummarySection = ({ data }: { data: { message: QueryReportReturnType } }) => {
|
||||
|
||||
const company = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { bankStatementBalanceAsPerGL, outstandingChecksDebit, outstandingChecksCredit, incorrectlyClearedEntriesDebit, incorrectlyClearedEntriesCredit, calculatedBankStatementBalance } = useMemo(() => {
|
||||
|
||||
// Loop over the results and find the corresponding rows
|
||||
|
||||
let bankStatementBalanceAsPerGL = 0
|
||||
|
||||
let outstandingChecksDebit = 0
|
||||
let outstandingChecksCredit = 0
|
||||
|
||||
let incorrectlyClearedEntriesDebit = 0
|
||||
let incorrectlyClearedEntriesCredit = 0
|
||||
|
||||
let calculatedBankStatementBalance = 0
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data?.message.result.forEach((r: any) => {
|
||||
if (r.payment_entry === 'Bank Statement balance as per General Ledger') {
|
||||
bankStatementBalanceAsPerGL = r.debit - r.credit
|
||||
}
|
||||
|
||||
if (r.payment_entry === 'Outstanding Checks and Deposits to clear') {
|
||||
outstandingChecksDebit = r.debit
|
||||
outstandingChecksCredit = r.credit
|
||||
}
|
||||
|
||||
if (r.payment_entry === 'Checks and Deposits incorrectly cleared') {
|
||||
incorrectlyClearedEntriesDebit = r.debit
|
||||
incorrectlyClearedEntriesCredit = r.credit
|
||||
}
|
||||
|
||||
if (r.payment_entry === 'Calculated Bank Statement balance') {
|
||||
calculatedBankStatementBalance = r.debit - r.credit
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
bankStatementBalanceAsPerGL,
|
||||
outstandingChecksDebit,
|
||||
outstandingChecksCredit,
|
||||
incorrectlyClearedEntriesDebit,
|
||||
incorrectlyClearedEntriesCredit,
|
||||
calculatedBankStatementBalance
|
||||
}
|
||||
|
||||
}, [data])
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(company)
|
||||
|
||||
return <div className="flex gap-4 items-start justify-between">
|
||||
<StatContainer>
|
||||
<StatLabel>{_("Bank Statement Balance as per General Ledger")}</StatLabel>
|
||||
<StatValue className="font-numeric">{formatCurrency(bankStatementBalanceAsPerGL, currency)}</StatValue>
|
||||
</StatContainer>
|
||||
|
||||
<StatContainer>
|
||||
<StatLabel>{_("Outstanding Checks and Deposits to clear")}</StatLabel>
|
||||
<StatValue className="font-numeric">{formatCurrency(outstandingChecksDebit - outstandingChecksCredit, currency)}</StatValue>
|
||||
</StatContainer>
|
||||
|
||||
{(incorrectlyClearedEntriesDebit > 0 || incorrectlyClearedEntriesCredit > 0) && <StatContainer>
|
||||
<StatLabel className="text-ink-red-3">{_("Checks and Deposits incorrectly cleared")}</StatLabel>
|
||||
<StatValue className="text-ink-red-3 font-numeric">{formatCurrency(incorrectlyClearedEntriesDebit - incorrectlyClearedEntriesCredit)}</StatValue>
|
||||
{/* <div className="" divider={<StackDivider height='20px' />}>
|
||||
{incorrectlyClearedEntriesDebit !== 0 && <StatHelpText>Debit: {formatCurrency(incorrectlyClearedEntriesDebit)}</StatHelpText>}
|
||||
{incorrectlyClearedEntriesCredit !== 0 && <StatHelpText>Credit: {formatCurrency(incorrectlyClearedEntriesCredit)}</StatHelpText>}
|
||||
</div> */}
|
||||
</StatContainer>}
|
||||
<StatContainer>
|
||||
<StatLabel>{_("Calculated Bank Statement Balance")}</StatLabel>
|
||||
<StatValue className="font-numeric">{formatCurrency(calculatedBankStatementBalance)}</StatValue>
|
||||
</StatContainer>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BankReconciliationStatement
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { ArrowDownRight, ArrowUpRight, CheckCircle2, ChevronDown, DollarSign, ExternalLink, ImportIcon, ListIcon, Search, Undo2, XCircle } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useGetBankTransactions } from "./utils"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import _ from "@/lib/translate"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import CurrencyInput from "react-currency-input-field"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { getCurrencySymbol } from "@/lib/currency"
|
||||
import { useDebounceValue } from "usehooks-ts"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription } from "@/components/ui/empty"
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
|
||||
|
||||
const BankTransactions = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!selectedBank || !dates) {
|
||||
return <MissingFiltersBanner text={_("Please select a bank and set the date range")} />
|
||||
}
|
||||
|
||||
return <>
|
||||
<BankTransactionListView />
|
||||
</>
|
||||
}
|
||||
|
||||
const BankTransactionListView = () => {
|
||||
|
||||
const { data, error } = useGetBankTransactions()
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const formattedFromDate = formatDate(dates.fromDate)
|
||||
const formattedToDate = formatDate(dates.toDate)
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onUndo = useCallback(
|
||||
(transaction: BankTransaction) => {
|
||||
setBankRecUnreconcileModalAtom(transaction.name)
|
||||
},
|
||||
[setBankRecUnreconcileModalAtom],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
() => bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ""),
|
||||
[bankAccount?.account_currency, bankAccount?.company],
|
||||
)
|
||||
|
||||
const transactionColumns = useMemo<ColumnDef<BankTransaction, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: _("Date"),
|
||||
size: 112,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.date),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: _("Description"),
|
||||
size: 250,
|
||||
// meta: { gridWidth: "minmax(0,2fr)" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => row.original.description,
|
||||
},
|
||||
{
|
||||
accessorKey: "reference_number",
|
||||
header: _("Reference #"),
|
||||
size: 128,
|
||||
cell: ({ row }) => row.original.reference_number,
|
||||
},
|
||||
{
|
||||
accessorKey: "withdrawal",
|
||||
header: _("Withdrawal"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.withdrawal, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "deposit",
|
||||
header: _("Deposit"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.deposit, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "unallocated_amount",
|
||||
header: _("Unallocated"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.unallocated_amount, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "transaction_type",
|
||||
header: _("Type"),
|
||||
size: 112,
|
||||
cell: ({ row }) =>
|
||||
row.original.transaction_type ? <Badge>{row.original.transaction_type}</Badge> : null,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: _("Status"),
|
||||
size: 168,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => {
|
||||
const tx = row.original
|
||||
if (!tx.allocated_amount || (tx.allocated_amount && tx.allocated_amount === 0)) {
|
||||
return (
|
||||
<Badge theme="red">
|
||||
<XCircle />
|
||||
{_("Not Reconciled")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (tx.allocated_amount && tx.allocated_amount > 0 && tx.unallocated_amount !== 0) {
|
||||
return (
|
||||
<Badge theme="orange">
|
||||
<CheckCircle2 />
|
||||
{_("Partially Reconciled")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge theme="green">
|
||||
<CheckCircle2 />
|
||||
{_("Reconciled")}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: _("Actions"),
|
||||
size: 200,
|
||||
enableResizing: false,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2 ps-0.5 items-center">
|
||||
<Button variant="ghost" asChild size='sm'>
|
||||
<a
|
||||
href={`/desk/bank-transaction/${row.original.name}`}
|
||||
target="_blank"
|
||||
|
||||
rel="noreferrer"
|
||||
// className="text-ink-gray-8 underline underline-offset-4 inline-flex gap-2"
|
||||
>
|
||||
{_("View")} <ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{row.original.allocated_amount && row.original.allocated_amount > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onUndo(row.original)}
|
||||
size="sm"
|
||||
theme='red'
|
||||
>
|
||||
<Undo2 />
|
||||
{_("Undo")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[_, accountCurrency, onUndo],
|
||||
)
|
||||
|
||||
const [search, setSearch] = useDebounceValue('', 250)
|
||||
const [amountFilter, setAmountFilter] = useState<{ value: number, stringValue?: string | number }>({ value: 0, stringValue: '0.00' })
|
||||
const [typeFilter, setTypeFilter] = useState('All')
|
||||
const [status, setStatus] = useState<'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled'>('All')
|
||||
|
||||
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value)
|
||||
}
|
||||
|
||||
const filteredResults = useMemo(() => {
|
||||
if (!data) {
|
||||
return []
|
||||
}
|
||||
|
||||
return data.message.filter((transaction) => {
|
||||
|
||||
if (search && !transaction.description?.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeFilter !== 'All') {
|
||||
if (typeFilter === 'Debits' && transaction.deposit && transaction.deposit > 0) {
|
||||
return false
|
||||
}
|
||||
if (typeFilter === 'Credits' && transaction.withdrawal && transaction.withdrawal > 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'All') {
|
||||
if (status === 'Reconciled' && transaction.status !== 'Reconciled') {
|
||||
return false
|
||||
}
|
||||
if (status === 'Unreconciled') {
|
||||
if (transaction.status === 'Reconciled') {
|
||||
return false
|
||||
}
|
||||
// Filter out partially reconciled transactions
|
||||
if (transaction.allocated_amount && transaction.allocated_amount > 0 && transaction.unallocated_amount !== 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (status === 'Partially Reconciled') {
|
||||
|
||||
if (transaction.status === 'Reconciled') {
|
||||
return false
|
||||
}
|
||||
if ((transaction.allocated_amount ?? 0) === 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (amountFilter.value > 0 && transaction.withdrawal !== amountFilter.value && transaction.deposit !== amountFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
}, [data, search, amountFilter, typeFilter, status])
|
||||
|
||||
return <div className="space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
|
||||
<Button size='md' variant='subtle' asChild>
|
||||
<Link to="/statement-importer">
|
||||
<ImportIcon />
|
||||
{_("Import Bank Statement")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.length > 0 && <Filters
|
||||
onSearchChange={onSearchChange}
|
||||
search={search}
|
||||
results={filteredResults}
|
||||
setAmountFilter={setAmountFilter}
|
||||
amountFilter={amountFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
typeFilter={typeFilter}
|
||||
status={status}
|
||||
setStatus={setStatus}
|
||||
/>}
|
||||
|
||||
{data && data.message.length > 0 ? (
|
||||
<ListView
|
||||
data={filteredResults}
|
||||
columns={transactionColumns}
|
||||
getRowId={(row) => row.name}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
emptyState={<Empty>
|
||||
<EmptyMedia>
|
||||
<ListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No bank transactions found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no transactions in the system for the selected bank account and dates that match the filters.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
interface FilterProps {
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
search: string
|
||||
results: BankTransaction[]
|
||||
setAmountFilter: (value: { value: number, stringValue?: string | number }) => void
|
||||
amountFilter: { value: number, stringValue?: string | number }
|
||||
onTypeFilterChange: (type: string) => void
|
||||
typeFilter: string
|
||||
status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled'
|
||||
setStatus: (status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled') => void
|
||||
}
|
||||
|
||||
|
||||
const Filters = ({
|
||||
onSearchChange,
|
||||
search,
|
||||
results,
|
||||
setAmountFilter,
|
||||
amountFilter,
|
||||
onTypeFilterChange,
|
||||
typeFilter,
|
||||
status,
|
||||
setStatus,
|
||||
|
||||
}: FilterProps) => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
const currencySymbol = getCurrencySymbol(currency)
|
||||
const formatInfo = getCurrencyFormatInfo(currency)
|
||||
const groupSeparator = formatInfo.group_sep || ","
|
||||
const decimalSeparator = formatInfo.decimal_str || "."
|
||||
|
||||
return <div className="flex py-2 w-full gap-2">
|
||||
<InputGroup variant='outline'>
|
||||
<label className="sr-only">{_("Search transactions")}</label>
|
||||
<InputGroupAddon>
|
||||
<Search className="w-4 h-4 text-ink-gray-5" />
|
||||
</InputGroupAddon>
|
||||
<Input
|
||||
placeholder={_("Search")} type='search' onChange={onSearchChange} variant='outline' defaultValue={search}
|
||||
className="border-none px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" />
|
||||
<InputGroupAddon align='inline-end'>
|
||||
<span className="text-sm text-ink-gray-5 text-nowrap whitespace-nowrap">{results?.length} {_(results?.length === 1 ? "result" : "results")}</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<div className="w-[25%]">
|
||||
<label className="sr-only">{_("Filter by amount")}</label>
|
||||
<CurrencyInput
|
||||
groupSeparator={groupSeparator}
|
||||
decimalSeparator={decimalSeparator}
|
||||
placeholder={`${currencySymbol}0${decimalSeparator}00`}
|
||||
decimalsLimit={2}
|
||||
value={amountFilter.stringValue}
|
||||
maxLength={12}
|
||||
decimalScale={2}
|
||||
prefix={currencySymbol}
|
||||
onValueChange={(v, _n, values) => {
|
||||
// If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals.
|
||||
// When the user eventually types the decimals or blurs out, the value is formatted anyway.
|
||||
// Otherwise store the float value
|
||||
// Check if the value ends with a decimal or a decimal with trailing zeroes
|
||||
const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0')
|
||||
const newValue = isDecimal ? v : values?.float ?? ''
|
||||
setAmountFilter({
|
||||
value: Number(newValue),
|
||||
stringValue: newValue
|
||||
})
|
||||
}}
|
||||
// @ts-expect-error - CurrencyInputProps doesn't have a variant prop but Input does
|
||||
variant={"outline"}
|
||||
customInput={Input}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[25%]">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size='md' className="min-w-32 w-full text-start justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
{typeFilter === 'All' ? <DollarSign className="w-4 h-4 text-ink-gray-5" /> : typeFilter === 'Debits' ? <ArrowUpRight className="w-4 h-4 text-ink-red-3" /> : <ArrowDownRight className="w-4 h-4 text-ink-green-3" />}
|
||||
{_(typeFilter)}
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('All')}><DollarSign /> {_("All")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('Debits')}><ArrowUpRight className="text-ink-red-3" /> {_("Debits")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('Credits')}><ArrowDownRight className="text-ink-green-3" /> {_("Credits")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="w-[25%]">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size='md' className="min-w-32 w-full text-start justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
{status === 'All' ? <ListIcon className="w-4 h-4 text-ink-gray-5" /> :
|
||||
status === 'Reconciled' ? <CheckCircle2 className="w-4 h-4 text-ink-green-3" /> :
|
||||
status === 'Unreconciled' ? <XCircle className="w-4 h-4 text-ink-red-3" /> :
|
||||
<CheckCircle2 className="w-4 h-4 text-yellow-500" />}
|
||||
{_(status)}
|
||||
</div>
|
||||
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setStatus('All')}>{<ListIcon className="w-4 h-4 text-ink-gray-5" />} {_("All")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus('Reconciled')}>{<CheckCircle2 className="w-4 h-4 text-ink-green-3" />} {_("Reconciled")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus('Unreconciled')}>{<XCircle className="w-4 h-4 text-ink-red-3" />} {_("Unreconciled")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus('Partially Reconciled')}>{<CheckCircle2 className="w-4 h-4 text-yellow-500" />} {_("Partially Reconciled")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BankTransactions
|
||||
@@ -0,0 +1,125 @@
|
||||
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useMemo } from "react"
|
||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import _ from "@/lib/translate"
|
||||
|
||||
const BankTransactionUnreconcileModal = () => {
|
||||
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
|
||||
return <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 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
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { selectedCompanyAtom, useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { useSetAtom } from "jotai"
|
||||
import { Building2, Check, ChevronDown } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import _ from "@/lib/translate"
|
||||
import { selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
|
||||
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
|
||||
|
||||
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
|
||||
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
|
||||
const selectedCompany = useCurrentCompany()
|
||||
|
||||
const handleSelectCompany = (company: string) => {
|
||||
setSelectedCompany(company)
|
||||
setSearchQuery("")
|
||||
setOpen(false)
|
||||
// Only reset bank account if the company is changed
|
||||
if (selectedCompany !== company) {
|
||||
setSelectedBankAccount(null)
|
||||
onChange?.(company)
|
||||
}
|
||||
}
|
||||
|
||||
return (<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type='button'
|
||||
role="combobox"
|
||||
size='md'
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 />
|
||||
{selectedCompany}
|
||||
</div>
|
||||
<ChevronDown className="text-ink-gray-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-56 w-fit p-0">
|
||||
<Command value={selectedCompany}>
|
||||
{options.length > 5 && <CommandInput placeholder={_("Search company...")} className="h-9" />}
|
||||
<CommandList>
|
||||
<CommandEmpty>{_("No company found.")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option: string) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={(currentValue) => {
|
||||
handleSelectCompany(currentValue)
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
<Check
|
||||
className={cn(
|
||||
"ms-auto",
|
||||
searchQuery === option ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>)
|
||||
}
|
||||
|
||||
export default CompanySelector
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { QueryReportReturnType } from "@/types/custom/Reports"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { getErrorMessage, slug } from "@/lib/frappe"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { PartyPopper } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
|
||||
|
||||
const IncorrectlyClearedEntries = () => {
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!companyID || !bankAccount || !dates) {
|
||||
const missingFields = []
|
||||
if (!companyID) {
|
||||
missingFields.push('Company')
|
||||
}
|
||||
if (!bankAccount) {
|
||||
missingFields.push('Bank Account')
|
||||
}
|
||||
if (!dates) {
|
||||
missingFields.push('Dates')
|
||||
}
|
||||
return <MissingFiltersBanner text={`Please select ${missingFields.join(', ')} to view the incorrectly cleared entries.`} />
|
||||
}
|
||||
|
||||
return <IncorrectlyClearedEntriesView />
|
||||
}
|
||||
|
||||
interface IncorrectlyClearedEntry {
|
||||
payment_document: string
|
||||
payment_entry: string
|
||||
debit: number
|
||||
credit: number
|
||||
posting_date: string,
|
||||
clearance_date: string,
|
||||
}
|
||||
|
||||
const IncorrectlyClearedEntriesView = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
company: companyID,
|
||||
account: bankAccount?.account,
|
||||
report_date: dates.toDate
|
||||
})
|
||||
}, [companyID, bankAccount, dates])
|
||||
|
||||
const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType<IncorrectlyClearedEntry> }>('frappe.desk.query_report.run', {
|
||||
report_name: 'Cheques and Deposits Incorrectly cleared',
|
||||
filters,
|
||||
ignore_prepared_report: 1,
|
||||
are_default_filters: false,
|
||||
}, `Report-Cheques and Deposits Incorrectly cleared-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
|
||||
|
||||
const formattedToDate = formatDate(dates.toDate)
|
||||
|
||||
const { call: clearClearingDate } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.clear_clearing_date')
|
||||
|
||||
const onClearClick = useCallback(
|
||||
(voucher_type: string, voucher_name: string) => {
|
||||
clearClearingDate({ voucher_type, voucher_name })
|
||||
.then(() => {
|
||||
toast.success(_("Cleared"), {
|
||||
duration: 1000,
|
||||
})
|
||||
mutate()
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(_("There was an error while performing the action."), {
|
||||
description: getErrorMessage(e),
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
},
|
||||
[clearClearingDate, mutate, _],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
() => bankAccount?.account_currency ?? getCompanyCurrency(companyID),
|
||||
[bankAccount?.account_currency, companyID],
|
||||
)
|
||||
|
||||
const incorrectlyClearedColumns = useMemo<ColumnDef<IncorrectlyClearedEntry, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "payment_document",
|
||||
header: _("Document Type"),
|
||||
size: 128,
|
||||
cell: ({ row }) => _(row.original.payment_document),
|
||||
},
|
||||
{
|
||||
id: "payment_entry",
|
||||
header: _("Payment Document"),
|
||||
size: 160,
|
||||
meta: {
|
||||
getTooltipText: (r) => {
|
||||
const x = r as IncorrectlyClearedEntry
|
||||
return [x.payment_document, x.payment_entry].filter(Boolean).join(" · ") || undefined
|
||||
},
|
||||
} satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/${slug(row.original.payment_document)}/${row.original.payment_entry}`}
|
||||
>
|
||||
{row.original.payment_entry}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "debit",
|
||||
header: _("Debit"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatCurrency(row.original.debit, accountCurrency),
|
||||
},
|
||||
{
|
||||
accessorKey: "credit",
|
||||
header: _("Credit"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatCurrency(row.original.credit, accountCurrency),
|
||||
},
|
||||
{
|
||||
accessorKey: "posting_date",
|
||||
header: _("Posting Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.posting_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "clearance_date",
|
||||
header: _("Clearance Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: _("Actions"),
|
||||
size: 180,
|
||||
enableResizing: false,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-ink-red-3 px-0"
|
||||
onClick={() => onClearClick(row.original.payment_document, row.original.payment_entry)}
|
||||
>
|
||||
{_("Reset Clearing Date")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[_, accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
}} />
|
||||
<br />
|
||||
{data && data.message.result.length > 0 && <span>
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
<br />
|
||||
{_("You can reset the clearing dates of these entries here.")}
|
||||
</span>}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
|
||||
<ListView
|
||||
data={data.message.result}
|
||||
columns={incorrectlyClearedColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.message.result.length === 0 &&
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
<PartyPopper />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("It's all good!")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no entries in the system where the clearance date is before the posting date.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
export default IncorrectlyClearedEntries
|
||||
@@ -0,0 +1,949 @@
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecAmountFilter, bankRecDateAtom, bankRecRecordJournalEntryModalAtom, bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecTransferModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { H4 } from "@/components/ui/typography"
|
||||
import { useMemo, useRef } from "react"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import Fuse from 'fuse.js'
|
||||
import { getSearchResults, LinkedPayment, UnreconciledTransaction, useGetRuleForTransaction, useGetUnreconciledTransactions, useGetVouchersForTransaction, useIsTransactionWithdrawal, useReconcileTransaction, useTransactionSearch } from "./utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { AlertCircleIcon, ArrowDownRight, ArrowRightIcon, ArrowRightLeft, ArrowUpRight, BadgeCheck, ChevronDown, DollarSign, Landmark, LandmarkIcon, ListIcon, Loader2, Receipt, ReceiptIcon, Search, User, XCircle, ZapIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import CurrencyInput from 'react-currency-input-field'
|
||||
import { getCurrencySymbol } from "@/lib/currency"
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import _ from "@/lib/translate"
|
||||
import TransferModal from "./TransferModal"
|
||||
import BankEntryModal from "./BankEntryModal"
|
||||
import RecordPaymentModal from "./RecordPaymentModal"
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import MatchFilters from "./MatchFilters"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { KeyboardMetaKeyIcon } from "@/components/ui/keyboard-keys"
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Link } from "react-router"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
||||
|
||||
const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!selectedBank) {
|
||||
return <Empty>
|
||||
<EmptyMedia>
|
||||
<LandmarkIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("Select a bank account to reconcile")}</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className={`flex items-start space-x-2`} >
|
||||
<div className="flex-1">
|
||||
<H4 className="text-sm font-medium">{_("Unreconciled Transactions")}</H4>
|
||||
<UnreconciledTransactions contentHeight={contentHeight} />
|
||||
</div>
|
||||
<Separator orientation="vertical" style={{ minHeight: `${contentHeight}px` }} />
|
||||
<div className="flex-1 px-1">
|
||||
<H4 className="text-sm font-medium">{_("Match or Create")}</H4>
|
||||
<VouchersSection contentHeight={contentHeight} />
|
||||
</div>
|
||||
</div>
|
||||
<TransferModal />
|
||||
<BankEntryModal />
|
||||
<RecordPaymentModal />
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
const currencySymbol = getCurrencySymbol(currency)
|
||||
const formatInfo = getCurrencyFormatInfo(currency)
|
||||
const groupSeparator = formatInfo.group_sep || ","
|
||||
const decimalSeparator = formatInfo.decimal_str || "."
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: unreconciledTransactions, isLoading, error } = useGetUnreconciledTransactions()
|
||||
|
||||
const [typeFilter, setTypeFilter] = useAtom(bankRecTransactionTypeFilter)
|
||||
const [amountFilter, setAmountFilter] = useAtom(bankRecAmountFilter)
|
||||
|
||||
const [search, setSearch] = useTransactionSearch()
|
||||
|
||||
const searchIndex = useMemo(() => {
|
||||
|
||||
if (!unreconciledTransactions) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Fuse(unreconciledTransactions.message, {
|
||||
keys: ['description', 'reference_number'],
|
||||
threshold: 0.5,
|
||||
includeScore: true
|
||||
})
|
||||
}, [unreconciledTransactions])
|
||||
|
||||
const results = useMemo(() => {
|
||||
|
||||
return getSearchResults(searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message)
|
||||
|
||||
}, [searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message])
|
||||
|
||||
const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(bankAccount?.name || ''))
|
||||
|
||||
const onFilterChange = () => {
|
||||
setSelectedTransaction([])
|
||||
}
|
||||
|
||||
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value)
|
||||
onFilterChange()
|
||||
}
|
||||
|
||||
const onTypeFilterChange = (type: string) => {
|
||||
setTypeFilter(type)
|
||||
onFilterChange()
|
||||
}
|
||||
|
||||
const onClearFilters = () => {
|
||||
setSearch('')
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
setTypeFilter('All')
|
||||
setAmountFilter({ value: 0, stringValue: '' })
|
||||
onFilterChange()
|
||||
}
|
||||
|
||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||
|
||||
if (isLoading) {
|
||||
return <UnreconciledTransactionsLoadingState />
|
||||
}
|
||||
|
||||
return <div className="space-y-1">
|
||||
<div className="flex py-2 w-full gap-2">
|
||||
|
||||
<InputGroup variant='outline'>
|
||||
<label className="sr-only">{_("Search transactions")}</label>
|
||||
<InputGroupAddon>
|
||||
<Search className="w-4 h-4 text-ink-gray-5" />
|
||||
</InputGroupAddon>
|
||||
<Input
|
||||
placeholder={_("Search")}
|
||||
// type='search'
|
||||
variant='outline'
|
||||
onChange={onSearchChange}
|
||||
defaultValue={search}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<InputGroupAddon align='inline-end'>
|
||||
<InputGroupText>{results?.length} {_(results?.length === 1 ? "result" : "results")}</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<div>
|
||||
<label className="sr-only">{_("Filter by amount")}</label>
|
||||
<CurrencyInput
|
||||
groupSeparator={groupSeparator}
|
||||
decimalSeparator={decimalSeparator}
|
||||
placeholder={`${currencySymbol}0${decimalSeparator}00`}
|
||||
decimalsLimit={2}
|
||||
value={amountFilter.stringValue}
|
||||
maxLength={12}
|
||||
decimalScale={2}
|
||||
prefix={currencySymbol}
|
||||
onValueChange={(v, _n, values) => {
|
||||
// If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals.
|
||||
// When the user eventually types the decimals or blurs out, the value is formatted anyway.
|
||||
// Otherwise store the float value
|
||||
// Check if the value ends with a decimal or a decimal with trailing zeroes
|
||||
const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0')
|
||||
const newValue = isDecimal ? v : values?.float ?? ''
|
||||
const nextAmountFilter = {
|
||||
value: Number(newValue),
|
||||
stringValue: newValue
|
||||
}
|
||||
const hasAmountFilterChanged = amountFilter.value !== nextAmountFilter.value || amountFilter.stringValue !== nextAmountFilter.stringValue
|
||||
|
||||
setAmountFilter(nextAmountFilter)
|
||||
|
||||
// `onValueChange` also fires on blur; avoid clearing selected transaction unless filter value actually changed.
|
||||
if (hasAmountFilterChanged) {
|
||||
onFilterChange()
|
||||
}
|
||||
}}
|
||||
// @ts-expect-error - CurrencyInputProps doesn't have a variant prop but Input does
|
||||
variant={"outline"}
|
||||
customInput={Input}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size='md' className="min-w-32 text-start">
|
||||
{typeFilter === 'All' ? <DollarSign className="text-ink-gray-5" /> : typeFilter === 'Debits' ? <ArrowUpRight className="text-ink-red-3" /> : <ArrowDownRight className="text-ink-green-3" />}
|
||||
{_(typeFilter)}
|
||||
<ChevronDown className="text-ink-gray-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('All')}><DollarSign /> {_("All")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('Debits')}><ArrowUpRight className="text-ink-red-3" /> {_("Debits")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('Credits')}><ArrowDownRight className="text-ink-green-3" /> {_("Credits")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<OlderUnreconciledTransactionsBanner />
|
||||
|
||||
{results.length === 0 && <NoTransactionsFoundBanner
|
||||
onClearFilters={hasFilters ? onClearFilters : undefined}
|
||||
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}
|
||||
/>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const NoTransactionsFoundBanner = ({ text, description, onClearFilters }: { text: string, description?: string, onClearFilters?: () => void }) => {
|
||||
|
||||
return <Empty>
|
||||
<EmptyMedia>
|
||||
<ListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{text}</EmptyTitle>
|
||||
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{onClearFilters ? <Button type='button' size='sm' variant='subtle' onClick={onClearFilters}>Clear Filters</Button> :
|
||||
<Button type='button' asChild size='sm' variant='subtle'>
|
||||
<Link to="/statement-importer">
|
||||
{_("Import Bank Statement")}
|
||||
</Link>
|
||||
</Button>}
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
const UnreconciledTransactionsLoadingState = () => {
|
||||
|
||||
return <div className="flex flex-col gap-2 py-2">
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<Skeleton className="h-9.5 w-full" />
|
||||
<Skeleton className="h-9.5 min-w-36" />
|
||||
<Skeleton className="h-9.5 min-w-32" />
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
const UnreconciledTransactionItem = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const [selectedTransaction, setSelectedTransaction] = useAtom(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
|
||||
const { amount, isWithdrawal } = useIsTransactionWithdrawal(transaction)
|
||||
|
||||
const isSelected = selectedTransaction?.some((t) => t.name === transaction.name)
|
||||
|
||||
const currency = transaction.currency ?? selectedBank?.account_currency ?? getCompanyCurrency(selectedBank?.company ?? '')
|
||||
|
||||
const handleSelectTransaction = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If the user is pressing the shift key, add/remove the transaction from the selected transactions
|
||||
if (event.shiftKey) {
|
||||
setSelectedTransaction(isSelected ? selectedTransaction.filter((t) => t.name !== transaction.name) : [...selectedTransaction, transaction])
|
||||
} else {
|
||||
setSelectedTransaction([transaction])
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="py-1">
|
||||
<div className={cn("border outline rounded-md p-2 mx-0.5 cursor-pointer transition-[color,box-shadow, bg] hover:bg-surface-gray-1",
|
||||
isSelected ? "bg-surface-gray-1 border-outline-gray-5 outline-outline-gray-5" : "border-outline-gray-2 outline-none"
|
||||
)}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleSelectTransaction}>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-sm">{formatDate(transaction.date)}</span>
|
||||
{transaction.transaction_type &&
|
||||
<Badge theme="blue">{transaction.transaction_type}</Badge>}
|
||||
{transaction.reference_number && <Badge
|
||||
title={transaction.reference_number}
|
||||
className="max-w-[300px] text-ellipsis"
|
||||
>
|
||||
{_("Ref")}: {transaction.reference_number}</Badge>}
|
||||
|
||||
{transaction.matched_transaction_rule && <Badge
|
||||
theme="violet"
|
||||
title={_("Matched by rule")}>
|
||||
<ZapIcon className="w-4 h-4" /> {transaction.matched_transaction_rule}</Badge>}
|
||||
</div>
|
||||
<span className="text-sm">{transaction.description}</span>
|
||||
</div>
|
||||
<div className="gap-1 flex flex-col items-end min-w-36 h-full text-end">
|
||||
{isWithdrawal ? <ArrowUpRight className="size-5 text-ink-red-3" /> : <ArrowDownRight className="size-5 text-ink-green-3" />}
|
||||
{amount && amount > 0 && <span className="font-semibold font-numeric text-base">{formatCurrency(amount, currency)}</span>}
|
||||
{amount !== transaction.unallocated_amount && <span className="text-xs leading-normal text-ink-gray-5">{formatCurrency(transaction.unallocated_amount, currency)} {_("Unallocated")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
|
||||
|
||||
if (selectedTransactions.length === 0) {
|
||||
return <Empty>
|
||||
<EmptyMedia>
|
||||
<ReceiptIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("Select a transaction to match and reconcile with vouchers")}</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
if (selectedTransactions.length > 1) {
|
||||
return <OptionsForMultipleTransactions transactions={selectedTransactions} />
|
||||
}
|
||||
|
||||
return <div style={{ minHeight: contentHeight }} className="mt-2">
|
||||
<OptionsForSingleTransaction transaction={selectedTransactions[0]} contentHeight={contentHeight} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const useKeyboardShortcuts = () => {
|
||||
const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
||||
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
useHotkeys('meta+p', () => {
|
||||
//
|
||||
setRecordPaymentModalOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
useHotkeys('meta+b', () => {
|
||||
//
|
||||
setRecordJournalEntryModalOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
useHotkeys('meta+i', () => {
|
||||
//
|
||||
setTransferModalOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
return {
|
||||
setTransferModalOpen,
|
||||
setRecordPaymentModalOpen,
|
||||
setRecordJournalEntryModalOpen
|
||||
}
|
||||
}
|
||||
|
||||
const OptionsForMultipleTransactions = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
|
||||
|
||||
return <div className="flex flex-col py-4">
|
||||
<Card className="gap-2">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-md font-medium">{transactions.length} {_(transactions.length === 1 ? _("transaction selected") : _("transactions selected"))}</span>
|
||||
<span className="text-md font-medium font-numeric">
|
||||
{formatCurrency(transactions.reduce((acc, transaction) => acc + (transaction.unallocated_amount ?? 0), 0), transactions[0].currency ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<CardAction className="mt-4 justify-self-center">
|
||||
<div className="flex gap-3 justify-center">
|
||||
<TooltipProvider>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size='md'
|
||||
aria-label={_("Record a bank journal entry for expenses, income or split transactions")}
|
||||
onClick={() => setRecordJournalEntryModalOpen(true)}>
|
||||
<Landmark /> {_("Bank Entry")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Record a journal entry for expenses, income or split transactions")}
|
||||
<KbdGroup className="ms-2">
|
||||
<Kbd><KeyboardMetaKeyIcon /></Kbd>
|
||||
<Kbd>B</Kbd>
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='md'
|
||||
aria-label={_("Record a payment entry against a customer or supplier")}
|
||||
onClick={() => setRecordPaymentModalOpen(true)}>
|
||||
<Receipt /> {_("Record Payment")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Record a payment entry against a customer or supplier")}
|
||||
<KbdGroup className="ms-2">
|
||||
<Kbd><KeyboardMetaKeyIcon /></Kbd>
|
||||
<Kbd>P</Kbd>
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='md'
|
||||
aria-label={_("Record an internal transfer to another bank/credit card/cash account")}
|
||||
onClick={() => setTransferModalOpen(true)}>
|
||||
<ArrowRightLeft /> {_("Transfer")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account")}
|
||||
<KbdGroup className="ms-2">
|
||||
<Kbd><KeyboardMetaKeyIcon /></Kbd>
|
||||
<Kbd>I</Kbd>
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
|
||||
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
|
||||
|
||||
return <div className="flex flex-col gap-3">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='md'
|
||||
aria-label={_("Record a payment entry against a customer or supplier")}
|
||||
onClick={() => setRecordPaymentModalOpen(true)}>
|
||||
<Receipt /> {_("Record Payment")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Record a payment entry against a customer or supplier")}
|
||||
<KbdGroup className="ms-2">
|
||||
<Kbd><KeyboardMetaKeyIcon /></Kbd>
|
||||
<Kbd>P</Kbd>
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='md'
|
||||
aria-label={_("Record a bank journal entry for expenses, income or split transactions")}
|
||||
onClick={() => setRecordJournalEntryModalOpen(true)}>
|
||||
<Landmark /> {_("Bank Entry")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Record a journal entry for expenses, income or split transactions")}
|
||||
<KbdGroup className="ms-2">
|
||||
<Kbd><KeyboardMetaKeyIcon /></Kbd>
|
||||
<Kbd>B</Kbd>
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip >
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='md'
|
||||
aria-label={_("Record an internal transfer to another bank/credit card/cash account")}
|
||||
onClick={() => setTransferModalOpen(true)}>
|
||||
<ArrowRightLeft /> {_("Transfer")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account")}
|
||||
<KbdGroup className="ms-2">
|
||||
<Kbd><KeyboardMetaKeyIcon /></Kbd>
|
||||
<Kbd>I</Kbd>
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<MatchFilters />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{transaction.matched_transaction_rule && <RuleAction transaction={transaction} />}
|
||||
<VouchersForTransaction transaction={transaction} contentHeight={contentHeight} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(transaction)
|
||||
const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
||||
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
if (!rule) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getActionIcon = () => {
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return <Landmark />
|
||||
case "Payment Entry":
|
||||
return <Receipt className="w-6 h-6" />
|
||||
case "Transfer":
|
||||
return <ArrowRightLeft />
|
||||
default:
|
||||
return <ZapIcon />
|
||||
}
|
||||
}
|
||||
|
||||
const getActionStyles = () => {
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return {
|
||||
border: "border-outline-blue-3",
|
||||
bg: "bg-surface-blue-1/50",
|
||||
text: "text-ink-blue-4",
|
||||
theme: "blue",
|
||||
}
|
||||
case "Payment Entry":
|
||||
return {
|
||||
border: "border-outline-green-3",
|
||||
bg: "bg-surface-green-1/50",
|
||||
text: "text-ink-green-4",
|
||||
theme: "green",
|
||||
}
|
||||
case "Transfer":
|
||||
return {
|
||||
border: "border-outline-violet-3",
|
||||
bg: "bg-surface-violet-2/50",
|
||||
text: "text-ink-violet-4",
|
||||
theme: "violet",
|
||||
}
|
||||
default:
|
||||
return {
|
||||
border: "border-outline-amber-3",
|
||||
bg: "bg-surface-amber-1/50",
|
||||
text: "text-ink-amber-4",
|
||||
theme: "orange",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionClick = () => {
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
setRecordJournalEntryModalOpen(true)
|
||||
break
|
||||
case "Payment Entry":
|
||||
setRecordPaymentModalOpen(true)
|
||||
break
|
||||
case "Transfer":
|
||||
setTransferModalOpen(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getActionDescription = () => {
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return _("Create a journal entry for expenses, income or split transactions")
|
||||
case "Payment Entry":
|
||||
return _("Record a payment entry against a customer or supplier")
|
||||
case "Transfer":
|
||||
return _("Record an internal transfer to another bank/credit card/cash account")
|
||||
default:
|
||||
return _("Create a new entry based on the rule")
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys('meta+r', () => {
|
||||
//
|
||||
handleActionClick()
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
const styles = getActionStyles()
|
||||
|
||||
return (
|
||||
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="flex justify-between items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`px-2.5 rounded-lg ${styles.bg} ${styles.text}`}>
|
||||
{getActionIcon()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-lg">{rule.rule_name}</span>
|
||||
<span className="text-sm text-ink-gray-5 font-normal">
|
||||
{rule.rule_description || _("Rule matched based on transaction description and other criteria.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Badge size='lg'
|
||||
theme={rule.classify_as === "Bank Entry" ? "blue" : rule.classify_as === "Payment Entry" ? "green" : rule.classify_as === "Transfer" ? "violet" : "orange"}>
|
||||
{rule.classify_as}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<div className="flex items-center justify-between p-2 bg-surface-white rounded-lg border border-outline-gray-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeCheck className="w-4 h-4 text-ink-green-3" />
|
||||
<span className="text-sm font-medium text-ink-gray-8">{_("Recommended Action")}</span>
|
||||
</div>
|
||||
<Badge variant="ghost" theme={styles.theme as "blue" | "green" | "violet" | "orange"}>
|
||||
{_("Priority")} {rule.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
{rule.account && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-ink-gray-8">{_("Account")}:</span>
|
||||
<span className="text-sm">{rule.account}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.party_type && rule.party && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-ink-gray-8">{_("Party")}:</span>
|
||||
<span className="text-sm">{rule.party} ({_(rule.party_type)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
onClick={handleActionClick}
|
||||
className={`w-full`}
|
||||
theme={styles.theme as "blue" | "green" | "violet"}
|
||||
size="md"
|
||||
>
|
||||
{getActionIcon()}
|
||||
<span>{_("Create")} {rule.classify_as}</span>
|
||||
</Button>
|
||||
<p className="text-sm text-ink-gray-5 mt-2 text-center leading-relaxed">
|
||||
{getActionDescription()}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
|
||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
|
||||
<Separator className="flex-1" />
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="relative space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
|
||||
<Separator className="flex-1" />
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
{vouchers?.message.length === 0 && <Empty className="my-4">
|
||||
<EmptyMedia>
|
||||
<ReceiptIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</div >
|
||||
}
|
||||
|
||||
const VoucherItem = ({ voucher, index }: { voucher: LinkedPayment, index: number }) => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
|
||||
const { amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested } = useMemo(() => {
|
||||
|
||||
const transaction = selectedTransaction?.[0]
|
||||
|
||||
// We need to check if the following details match:
|
||||
// Amount
|
||||
// Date
|
||||
// Reference/Description: Full or partial
|
||||
// Whether this is suggested or not - depends on the above scores
|
||||
|
||||
const amountMatches = voucher.paid_amount === transaction?.unallocated_amount
|
||||
const postingDateMatches = voucher.posting_date === transaction?.date
|
||||
const referenceDateMatches = voucher.reference_date === transaction?.date
|
||||
const referenceMatchesFull = voucher.reference_no === transaction?.reference_number || voucher.reference_no === transaction?.description
|
||||
|
||||
const referenceMatchesPartial = transaction?.reference_number?.includes(voucher.reference_no) || transaction?.description?.includes(voucher.reference_no)
|
||||
|
||||
|
||||
const isSuggested = amountMatches && (postingDateMatches || referenceDateMatches || referenceMatchesPartial) && index === 0
|
||||
|
||||
return { isSelected: false, amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested: isSuggested }
|
||||
|
||||
}, [voucher, selectedTransaction, index])
|
||||
|
||||
const { reconcileTransaction, loading } = useReconcileTransaction()
|
||||
|
||||
const onClick = () => {
|
||||
if (!selectedTransaction) {
|
||||
return
|
||||
}
|
||||
reconcileTransaction(selectedTransaction[0], voucher)
|
||||
}
|
||||
|
||||
return <div className="py-1 px-1">
|
||||
<div
|
||||
className={cn("border outline overflow-hidden relative rounded-md p-2",
|
||||
isSuggested ? "border-outline-green-4 bg-surface-green-1/40 outline-outline-green-4" : "border-outline-gray-2 outline-transparent"
|
||||
)}
|
||||
>
|
||||
|
||||
<div className="flex justify-between items-end gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge size='md'>{_(voucher.doctype)}</Badge>
|
||||
<a target="_blank"
|
||||
href={`/desk/${slug(voucher.doctype)}/${voucher.name}`}
|
||||
className="underline underline-offset-2 text-base"
|
||||
>{voucher.name}</a>
|
||||
</div>
|
||||
{voucher.party && voucher.party_type && <div className="flex items-center gap-1.5 text-base">
|
||||
<User size='18px' />
|
||||
<span>{_(voucher.party_type)}</span>
|
||||
<a target="_blank"
|
||||
href={`/desk/${slug(voucher.party_type)}/${voucher.party}`}
|
||||
className="underline underline-offset-2"
|
||||
>{voucher.party}</a>
|
||||
</div>}
|
||||
<TooltipProvider>
|
||||
<div className="flex items-start gap-8 py-0.5">
|
||||
<div className="flex flex-col gap-1 min-w-24">
|
||||
<div className="text-xs text-ink-gray-6">{_("Amount")}</div>
|
||||
<div className="text-base font-medium flex items-center gap-1">{formatCurrency(voucher.paid_amount, voucher.currency)} {amountMatches ? <MatchBadge matchType="full" label={_("Amount matches the selected transaction")} /> : <MatchBadge matchType="none" label={_("Amount does not match the selected transaction")} />}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-24">
|
||||
<div className="text-xs text-ink-gray-6">{_("Posted On")}</div>
|
||||
<div className="text-base font-medium flex items-center gap-1">{formatDate(voucher.posting_date)} {postingDateMatches ? <MatchBadge matchType="full" label={_("Posting date matches the selected transaction")} /> : <MatchBadge matchType="none" label={_("Posting date does not match the selected transaction")} />}</div>
|
||||
</div>
|
||||
|
||||
{voucher.reference_date && <div className="flex flex-col gap-1 min-w-24">
|
||||
<div className="text-xs text-ink-gray-6">{_("Reference Date")}</div>
|
||||
<div className="text-base font-medium flex items-center gap-1">{formatDate(voucher.reference_date)} {referenceDateMatches ? <MatchBadge matchType="full" label={_("Reference date matches the selected transaction")} /> : <MatchBadge matchType="none" label={_("Reference date does not match the selected transaction")} />}</div>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
{voucher.reference_no && <div className="flex items-start gap-1">
|
||||
<span className="text-p-base">
|
||||
{voucher.reference_no}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge theme={referenceMatchesFull ? "green" : referenceMatchesPartial ? "orange" : "red"} variant={referenceMatchesFull || referenceMatchesPartial ? "subtle" : "outline"}>
|
||||
{referenceMatchesFull ? `${_("Complete Match")}` : referenceMatchesPartial ? `${_("Partial Match")}` : `${_("No Match")}`}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{referenceMatchesFull ? `${_("Reference matches the selected transaction")}` : referenceMatchesPartial ? `${_("Reference matches the selected transaction partially")}` : `${_("Reference does not match the selected transaction")}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant={isSuggested || amountMatches ? "solid" : "outline"}
|
||||
theme={isSuggested || amountMatches ? "green" : "gray"}
|
||||
onClick={onClick} disabled={loading}>{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {_("Reconciling")}...</> : `${_("Reconcile")}`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSuggested && <div className="absolute top-1.5 end-2 flex items-center gap-1 justify-center">
|
||||
<Badge theme="green" variant="subtle" size='md'>{_("Suggested")}</Badge>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
const MatchBadge = ({ matchType, label }: { matchType: 'full' | 'partial' | 'none', label: string }) => {
|
||||
return <Tooltip>
|
||||
<TooltipTrigger>
|
||||
{matchType === 'full' ? <BadgeCheck className="text-ink-white fill-surface-green-5 size-4" /> : matchType === 'partial' ?
|
||||
<Badge theme="orange" variant="subtle">{_("Partial Match")}</Badge> :
|
||||
<XCircle className="text-ink-red-4 size-4" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
const OlderUnreconciledTransactionsBanner = () => {
|
||||
|
||||
// A banner to show when there are unreconciled transactions for the given bank account before the current selected date
|
||||
const [dates, setDates] = useAtom(bankRecDateAtom)
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { data } = useFrappeGetCall<{
|
||||
message: {
|
||||
count: number,
|
||||
oldest_date: string
|
||||
}
|
||||
}>("erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_older_unreconciled_transactions", {
|
||||
bank_account: selectedBank?.name,
|
||||
from_date: dates.fromDate,
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
|
||||
if (data && data.message.count > 0) {
|
||||
|
||||
return <Alert theme='gray' variant='subtle'>
|
||||
<AlertCircleIcon />
|
||||
<div className="flex justify-between items-center gap-1.5">
|
||||
<div>
|
||||
<AlertTitle> {data.message.count > 1 ? (
|
||||
<span>{_("There are {0} unreconciled transactions before {1}.", [data.message.count.toString(), formatDate(dates.fromDate)])}</span>
|
||||
) : (
|
||||
<span>{_("There is one unreconciled transaction before {0}.", [formatDate(dates.fromDate)])}</span>
|
||||
)}</AlertTitle>
|
||||
<AlertDescription className="flex justify-between text-balance">
|
||||
{_("The opening balance might not match your bank statement. Would you like to reconcile them?")}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size='sm'
|
||||
type='button'
|
||||
theme='gray'
|
||||
variant='outline'
|
||||
onClick={() => setDates({ fromDate: data.message.oldest_date, toDate: dates.toDate })}>
|
||||
<span>{data.message.count > 1 ? _("View older transactions") : _("View older transaction")}</span>
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
}
|
||||
|
||||
export default MatchAndReconcile
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { FilterIcon } from 'lucide-react'
|
||||
import { bankRecMatchFilters } from './bankRecAtoms'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useFrappeGetCall } from 'frappe-react-sdk'
|
||||
import { scrub } from '@/lib/frappe'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const MatchFilters = () => {
|
||||
return (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size='md' isIconButton variant='outline' aria-label={_("Configure match filters for vouchers")}>
|
||||
<FilterIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<TooltipContent>
|
||||
{_("Configure match filters for vouchers")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ToggleSwitch label={_("Show Only Exact Amount")} id="exact_match" />
|
||||
<Separator />
|
||||
<MatchFiltersContent />
|
||||
<ToggleSwitch label={_("Bank Transaction")} id="bank_transaction" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const MatchFiltersContent = () => {
|
||||
|
||||
const { data } = useFrappeGetCall<{ message: string[] }>("erpnext.accounts.doctype.bank_transaction.bank_transaction.get_doctypes_for_bank_reconciliation", undefined,
|
||||
"bank_rec_doctypes", {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
)
|
||||
|
||||
const doctypes = useMemo(() => {
|
||||
const STANDARD_DOCTYPES = ["Payment Entry", "Journal Entry", "Purchase Invoice", "Sales Invoice"]
|
||||
if (data) {
|
||||
return data.message.map(doctype => ({
|
||||
label: doctype,
|
||||
id: scrub(doctype),
|
||||
}))
|
||||
|
||||
} else {
|
||||
return STANDARD_DOCTYPES.map(doctype => ({
|
||||
label: doctype,
|
||||
id: scrub(doctype),
|
||||
}))
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{doctypes.map((doctype) => (
|
||||
<ToggleSwitch key={doctype.id} label={doctype.label} id={doctype.id} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleSwitch = ({ label, id }: { label: string, id: string }) => {
|
||||
|
||||
const [matchFilters, setMatchFilters] = useAtom(bankRecMatchFilters)
|
||||
|
||||
return <div className="flex items-center space-x-2">
|
||||
<Switch id={id} checked={matchFilters.includes(id)} onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setMatchFilters([...matchFilters, id])
|
||||
} else {
|
||||
setMatchFilters(matchFilters.filter(filter => filter !== id))
|
||||
}
|
||||
}} />
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default MatchFilters
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReactNode } from "react"
|
||||
|
||||
|
||||
export const MissingFiltersBanner = ({ text, className }: { text: ReactNode, className?: string }) => {
|
||||
return <div className={cn("min-h-[50vh] flex items-center justify-center", className)}>
|
||||
<Paragraph>{text}</Paragraph>
|
||||
</div>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import _ from "@/lib/translate"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
import { useFrappeCreateDoc } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import { RuleForm } from "./RuleForm"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { SettingsPanelHeader, SettingsPanelDescription, SettingsPanelTitle, SettingsPanelContent } from "@/components/ui/settings-dialog"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
type Props = {
|
||||
onCreate: VoidFunction
|
||||
}
|
||||
|
||||
const CreateNewRule = ({ onCreate }: Props) => {
|
||||
|
||||
const currentCompany = useCurrentCompany()
|
||||
|
||||
const form = useForm<BankTransactionRule>({
|
||||
defaultValues: {
|
||||
rule_name: "",
|
||||
company: currentCompany,
|
||||
rule_description: "",
|
||||
transaction_type: "Any",
|
||||
classify_as: 'Bank Entry',
|
||||
bank_entry_type: "Single Account",
|
||||
description_rules: [{
|
||||
check: "Contains",
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const { createDoc, loading, error } = useFrappeCreateDoc<BankTransactionRule>()
|
||||
|
||||
const onSubmit = (data: BankTransactionRule) => {
|
||||
createDoc("Bank Transaction Rule", data)
|
||||
.then(() => {
|
||||
toast.success(_("Rule created successfully"))
|
||||
onCreate()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanelHeader
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant='outline' size='md' type='button' onClick={() => onCreate()}>{_("Cancel")}</Button>
|
||||
<Button type='submit' form='rule-form' size='md' disabled={loading}>
|
||||
{_("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsPanelTitle>
|
||||
{_("New Rule")}
|
||||
</SettingsPanelTitle>
|
||||
<SettingsPanelDescription>
|
||||
{_("Create a new rule to automatically classify transactions.")}
|
||||
</SettingsPanelDescription>
|
||||
</SettingsPanelHeader>
|
||||
<SettingsPanelContent className="px-0">
|
||||
<Form {...form}>
|
||||
<form id='rule-form' onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col justify-between h-full overflow-y-auto px-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<RuleForm />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsPanelContent>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateNewRule
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import _ from "@/lib/translate"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
import { FrappeError, useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import { RuleForm } from "./RuleForm"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { SettingsPanelContent, SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle } from "@/components/ui/settings-dialog"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
type Props = {
|
||||
onClose: VoidFunction,
|
||||
ruleID: string
|
||||
}
|
||||
|
||||
const EditRule = ({ onClose, ruleID }: Props) => {
|
||||
|
||||
const { data: rule, isValidating, error, mutate } = useFrappeGetDoc<BankTransactionRule>("Bank Transaction Rule", ruleID, undefined, {
|
||||
revalidateOnMount: true
|
||||
})
|
||||
|
||||
const { updateDoc, loading, error: updateError } = useFrappeUpdateDoc<BankTransactionRule>()
|
||||
|
||||
const onSubmit = (data: BankTransactionRule) => {
|
||||
updateDoc("Bank Transaction Rule", ruleID, data)
|
||||
.then(() => {
|
||||
toast.success(_("Rule updated."))
|
||||
mutate()
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
return <>
|
||||
<SettingsPanelHeader
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant='outline' size='md' type='button' onClick={() => onClose()}>{_("Cancel")}</Button>
|
||||
<Button type='submit' form='rule-form' size='md' disabled={isValidating || loading}>
|
||||
{_("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsPanelTitle>
|
||||
{rule?.rule_name}
|
||||
</SettingsPanelTitle>
|
||||
<SettingsPanelDescription className="sr-only">
|
||||
{_("Edit this rule")}
|
||||
</SettingsPanelDescription>
|
||||
</SettingsPanelHeader>
|
||||
<SettingsPanelContent className="px-0">
|
||||
{isValidating && <div className="px-4 flex flex-col gap-4 h-full">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>}
|
||||
|
||||
{error && <div className="px-4 flex flex-col gap-4 h-full">
|
||||
<ErrorBanner error={error} />
|
||||
</div>}
|
||||
{rule && <EditRuleForm rule={rule} onSubmit={onSubmit} error={updateError} />}
|
||||
</SettingsPanelContent>
|
||||
</>
|
||||
|
||||
|
||||
}
|
||||
|
||||
const EditRuleForm = ({ rule, onSubmit, error }: { rule: BankTransactionRule, onSubmit: (data: BankTransactionRule) => void, error?: FrappeError | null }) => {
|
||||
|
||||
const form = useForm<BankTransactionRule>({
|
||||
defaultValues: {
|
||||
...rule,
|
||||
}
|
||||
})
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id='rule-form' onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col justify-between h-full overflow-y-auto px-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<RuleForm isEdit />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditRule
|
||||
@@ -0,0 +1,799 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"
|
||||
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
|
||||
import { AccountFormField, CurrencyFormField, DataField, LinkFormField, PartyTypeFormField, SelectFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { SelectItem } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { today } from "@/lib/date"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
import { BankTransactionRuleAccounts } from "@/types/Accounts/BankTransactionRuleAccounts"
|
||||
import { FrappeConfig, FrappeContext } from "frappe-react-sdk"
|
||||
import { ArrowDownRight, ArrowDownUp, ArrowRightLeftIcon, ArrowUpRight, LandmarkIcon, Plus, PlusCircleIcon, ReceiptIcon, Settings, Trash2 } from "lucide-react"
|
||||
import { ChangeEvent, useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form"
|
||||
|
||||
export const RuleForm = ({ isEdit = false }: { isEdit?: boolean }) => {
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<DataField
|
||||
name='rule_name'
|
||||
label={_("Rule Name")}
|
||||
disabled={isEdit}
|
||||
isRequired
|
||||
inputProps={{
|
||||
maxLength: 140,
|
||||
disabled: isEdit,
|
||||
placeholder: _("Bank Charges, Salary, etc."),
|
||||
autoFocus: true,
|
||||
className: "dark:disabled:bg-surface-gray-2"
|
||||
}}
|
||||
rules={{
|
||||
required: _("Rule name is required")
|
||||
}}
|
||||
/>
|
||||
|
||||
<CompanySelector />
|
||||
|
||||
<SmallTextField
|
||||
name='rule_description'
|
||||
label={_("Rule Description")}
|
||||
inputProps={{
|
||||
placeholder: _("Any debit transaction with the keyword 'Bank Fee'.")
|
||||
}}
|
||||
/>
|
||||
|
||||
<TransactionTypeSelector />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<CurrencyFormField
|
||||
name='min_amount'
|
||||
label={_("Minimum Amount")}
|
||||
/>
|
||||
|
||||
<CurrencyFormField
|
||||
name='max_amount'
|
||||
label={_("Maximum Amount")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DescriptionRules />
|
||||
|
||||
<Separator />
|
||||
|
||||
<RuleAction />
|
||||
</div>
|
||||
}
|
||||
|
||||
const CompanySelector = () => {
|
||||
|
||||
const { setValue } = useFormContext<BankTransactionRule>()
|
||||
|
||||
return <LinkFormField
|
||||
name='company'
|
||||
label={_("Company")}
|
||||
doctype="Company"
|
||||
isRequired
|
||||
rules={{
|
||||
required: _("Company is required"),
|
||||
onChange: () => {
|
||||
setValue('account', '')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
/** Component to render a radio group as a toggle group with options for All, Withdrawal, Deposit */
|
||||
const TransactionTypeSelector = () => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name='transaction_type'
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{_("Transaction Type")}<span className="text-ink-red-3">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="grid grid-cols-3 gap-2 w-full"
|
||||
>
|
||||
<FormItem className="flex items-center">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="Any"
|
||||
className="peer sr-only hidden"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
|
||||
"peer-data-[state=checked]:bg-surface-gray-7 peer-data-[state=checked]:text-ink-white peer-data-[state=checked]:border-outline-gray-5 peer-data-[state=checked]:hover:bg-surface-gray-7 peer-data-[state=checked]:hover:text-ink-white"
|
||||
)}
|
||||
>
|
||||
<ArrowDownUp className="w-5 h-5" />
|
||||
{_("All")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="Withdrawal"
|
||||
className="peer sr-only hidden"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
|
||||
"peer-data-[state=checked]:bg-surface-red-5 peer-data-[state=checked]:text-white peer-data-[state=checked]:border-bg-surface-red-5 peer-data-[state=checked]:hover:bg-surface-red-5 peer-data-[state=checked]:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight className="w-5 h-5 peer-data-[state=checked]:text-ink-red-3" />
|
||||
{_("Withdrawal")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="Deposit"
|
||||
className="peer sr-only hidden"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
|
||||
"peer-data-[state=checked]:bg-surface-green-5 peer-data-[state=checked]:text-white peer-data-[state=checked]:border-surface-green-5 peer-data-[state=checked]:hover:bg-surface-green-5 peer-data-[state=checked]:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<ArrowDownRight className="w-5 h-5 peer-data-[state=checked]:text-white" />
|
||||
{_("Deposit")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DescriptionRules = () => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "description_rules"
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
// @ts-expect-error - we don't need all fields here
|
||||
append({ check: "Contains" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<span className="text-sm font-medium">{_("Rules to match against the transaction description")} <span className="text-ink-red-3">*</span></span>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex w-full items-center gap-2">
|
||||
<div className="min-w-36">
|
||||
<SelectFormField
|
||||
label={_("Type of check")}
|
||||
hideLabel
|
||||
name={`description_rules.${index}.check`}
|
||||
rules={{
|
||||
required: _("This is required")
|
||||
}}>
|
||||
<SelectItem value="Contains">{_("Contains")}</SelectItem>
|
||||
<SelectItem value="Starts With">{_("Starts with")}</SelectItem>
|
||||
<SelectItem value="Ends With">{_("Ends with")}</SelectItem>
|
||||
<SelectItem value="Regex">{_("Regex")}</SelectItem>
|
||||
</SelectFormField>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<DataField
|
||||
name={`description_rules.${index}.value`}
|
||||
label={_("Value")}
|
||||
hideLabel
|
||||
inputProps={{
|
||||
placeholder: _("Bank Fee, Salary, etc."),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="ghost" theme='red' type='button' isIconButton onClick={() => remove(index)} disabled={fields.length === 1}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Button variant="outline" type='button' onClick={addRow}>
|
||||
<PlusCircleIcon />
|
||||
{_("Add Rule")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RuleAction = () => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const classify_as = useWatch({ control, name: "classify_as" })
|
||||
const party_type = useWatch({ control, name: "party_type" })
|
||||
const bank_entry_type = useWatch({ control, name: "bank_entry_type" })
|
||||
|
||||
const accountType = useMemo(() => {
|
||||
if (classify_as === "Payment Entry") {
|
||||
return party_type === "Supplier" ? ["Payable"] : ["Receivable"]
|
||||
}
|
||||
|
||||
if (classify_as === "Transfer") {
|
||||
return ["Bank", "Cash", "Temporary"]
|
||||
}
|
||||
|
||||
return undefined
|
||||
|
||||
}, [classify_as, party_type])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<H4 className="text-base text-ink-gray-7">{_("If rule matches, then:")}</H4>
|
||||
|
||||
<SelectFormField
|
||||
name='classify_as'
|
||||
isRequired
|
||||
label={_("Suggest creating a")}
|
||||
formDescription={_("This will just suggest creating a new entry, and will not automatically create it.")}
|
||||
rules={{
|
||||
required: _("This is required")
|
||||
}}
|
||||
>
|
||||
<SelectItem value="Bank Entry"><LandmarkIcon /> {_("Bank Entry")}</SelectItem>
|
||||
<SelectItem value="Payment Entry"><ReceiptIcon /> {_("Payment Entry")}</SelectItem>
|
||||
<SelectItem value="Transfer"><ArrowRightLeftIcon /> {_("Transfer")}</SelectItem>
|
||||
</SelectFormField>
|
||||
|
||||
{classify_as === "Bank Entry" && (<SelectFormField
|
||||
name='bank_entry_type'
|
||||
isRequired
|
||||
label={_("Create Bank Entry against")}
|
||||
rules={{
|
||||
required: _("This is required")
|
||||
}}
|
||||
>
|
||||
<SelectItem value="Single Account">{_("Single Account")}</SelectItem>
|
||||
<SelectItem value="Multiple Accounts">{_("Multiple Accounts (Journal Template)")}</SelectItem>
|
||||
</SelectFormField>)}
|
||||
|
||||
|
||||
{classify_as === "Payment Entry" && (
|
||||
<div className='grid grid-cols-4 gap-4'>
|
||||
<div className="col-span-1">
|
||||
<PartyTypeFormField
|
||||
name='party_type'
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
triggerProps: {
|
||||
className: 'w-full'
|
||||
},
|
||||
}}
|
||||
rules={{
|
||||
required: "Party Type is required"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<PartyField />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(((bank_entry_type === "Single Account" || !bank_entry_type) && classify_as === "Bank Entry") || classify_as !== "Bank Entry") && (<AccountFormField
|
||||
name='account'
|
||||
label={_("Account")}
|
||||
isRequired
|
||||
rules={{
|
||||
required: _("Account is required")
|
||||
}}
|
||||
account_type={accountType}
|
||||
/>)}
|
||||
|
||||
{bank_entry_type === "Multiple Accounts" && classify_as === "Bank Entry" && <MultipleAccountsSelection />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PartyField = () => {
|
||||
|
||||
const { control, setValue } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `party_type`
|
||||
})
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const company = useWatch({ control, name: 'company' })
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
// Fetch the party and account
|
||||
if (event.target.value) {
|
||||
call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', {
|
||||
company: company,
|
||||
party_type: party_type,
|
||||
party: event.target.value,
|
||||
date: today()
|
||||
}).then((res) => {
|
||||
setValue('account', res.message.party_account)
|
||||
})
|
||||
} else {
|
||||
// Clear the account
|
||||
setValue('account', '')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange
|
||||
}}
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
|
||||
const MultipleAccountsSelection = () => {
|
||||
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const accounts = useWatch({
|
||||
control,
|
||||
name: 'accounts'
|
||||
}) ?? []
|
||||
|
||||
const [isConfigureAccountsModalOpen, setIsConfigureAccountsModalOpen] = useState(false)
|
||||
|
||||
|
||||
|
||||
return <div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<Label>{_("Journal Template Accounts")}<span className="text-ink-red-3">*</span></Label>
|
||||
<Button variant="outline" type="button" onClick={() => setIsConfigureAccountsModalOpen(true)}><Settings /> {_("Configure Accounts")}</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center">
|
||||
<div className="py-2 flex flex-col gap-2 items-center">
|
||||
<span>{_("No accounts configured")}</span>
|
||||
<Button variant="subtle" type="button" onClick={() => setIsConfigureAccountsModalOpen(true)}>{_("Configure Accounts")}</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{accounts.map((account, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{account.account}</TableCell>
|
||||
{index === accounts.length - 1 ? <TableCell className="text-end bg-surface-gray-1" colSpan={2}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-ink-gray-5">{_("This is auto computed to balance the journal entry.")}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Based on the above entries, the balance amount (debit or credit) will be set for the last row to balance the journal entry.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell> : <>
|
||||
<TableCell className="font-numeric text-end"><AmountFormulaRenderer value={account.debit} /></TableCell>
|
||||
<TableCell className="font-numeric text-end"><AmountFormulaRenderer value={account.credit} /></TableCell>
|
||||
</>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<ConfigureAccountsModal open={isConfigureAccountsModalOpen} onClose={() => setIsConfigureAccountsModalOpen(false)} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const AmountFormulaRenderer = ({ value }: { value?: string }) => {
|
||||
|
||||
// If it's a string and cannot be a number, then show it as a formula
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
|
||||
let calculatedValue = "";
|
||||
|
||||
try {
|
||||
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
calculatedValue = "Error";
|
||||
}
|
||||
|
||||
const isComputationValid = !isNaN(Number(calculatedValue)) && calculatedValue !== undefined && calculatedValue !== null;
|
||||
|
||||
return <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("font-numeric text-end tabular-nums underline underline-offset-4", isComputationValid ? "" : "text-ink-red-3")}>{value}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={isComputationValid ? "" : "bg-surface-red-5"} arrowClassName={isComputationValid ? "" : "bg-surface-red-5 fill-surface-red-5"}>
|
||||
<p className="text-sm">
|
||||
{isComputationValid ? _("This is a formula based value.") : _("This is not a valid formula. Check the variable used in the formula.")}
|
||||
<br /><br />
|
||||
{_("Example: If the transaction amount is 200, then this will be calculated as {} = {}", [value ?? "", calculatedValue])}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
return <span className="font-numeric text-end tabular-nums">{value}</span>
|
||||
}
|
||||
|
||||
const ConfigureAccountsModal = ({ open, onClose }: { open: boolean, onClose: () => void }) => {
|
||||
|
||||
|
||||
return <Dialog
|
||||
open={open}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<ConfigureAccountsModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
const ConfigureAccountsModalContent = () => {
|
||||
|
||||
const { control, getValues, setValue } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
// const costCenterMapRef = useRef<Record<string, string>>({})
|
||||
|
||||
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(`accounts.${index}.account`, partyMapRef.current[value])
|
||||
} else {
|
||||
call.get('erpnext.accounts.party.get_party_account', {
|
||||
party: value,
|
||||
party_type: getValues(`accounts.${index}.party_type`),
|
||||
company: company
|
||||
}).then((result: { message: string }) => {
|
||||
setValue(`accounts.${index}.account`, result.message)
|
||||
partyMapRef.current[value] = result.message
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setValue(`accounts.${index}.account`, '')
|
||||
}
|
||||
}
|
||||
|
||||
const transaction_type = useWatch({
|
||||
name: 'transaction_type',
|
||||
control,
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'accounts'
|
||||
})
|
||||
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<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 onAdd = () => {
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: '',
|
||||
credit: '',
|
||||
user_remark: ''
|
||||
} as BankTransactionRuleAccounts, {
|
||||
focusName: `accounts.${fields.length}.account`
|
||||
})
|
||||
}
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
remove(selectedRows)
|
||||
setSelectedRows([])
|
||||
}, [remove, selectedRows])
|
||||
|
||||
const isWithdrawal = transaction_type === 'Withdrawal'
|
||||
|
||||
const company = useWatch({
|
||||
name: 'company',
|
||||
control,
|
||||
})
|
||||
|
||||
return <>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Configure Accounts for Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>{_("Add all accounts that you want to split the transaction into.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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")} <span className="text-ink-red-3">*</span></TableHead>
|
||||
{/* <TableHead>{_("Cost Center")}</TableHead> */}
|
||||
<TableHead>{_("Remarks")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="bg-surface-gray-1 cursor-not-allowed" title={_("This is the row for the bank account. It will be auto populated based on the bank transaction.")}>
|
||||
<TableCell>
|
||||
<Checkbox disabled />
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-ink-gray-5">
|
||||
<span className="px-2">
|
||||
Bank GL Account
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={"align-top text-end"}>
|
||||
<span className="text-ink-gray-5 text-sm">
|
||||
{transaction_type === "Withdrawal" || transaction_type === "Any" ? _("Will be auto-populated") : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={"text-end align-top"}>
|
||||
<span className="text-ink-gray-5 text-sm">
|
||||
{transaction_type === "Deposit" || transaction_type === "Any" ? _("Will be auto-populated") : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{fields.map((field, index) => (
|
||||
<TableRow key={field.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(index)}
|
||||
onCheckedChange={() => onSelectRow(index)}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select row {0}", [String(index + 1)])}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="align-top">
|
||||
<div className="flex">
|
||||
<PartyTypeFormField
|
||||
name={`accounts.${index}.party_type`}
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
hideLabel
|
||||
inputProps={{
|
||||
type: isWithdrawal ? 'Payable' : 'Receivable',
|
||||
triggerProps: {
|
||||
className: 'rounded-e-none',
|
||||
tabIndex: -1
|
||||
},
|
||||
}} />
|
||||
<PartyRowField index={index} onChange={onPartyChange} />
|
||||
</div>
|
||||
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AccountFormField
|
||||
name={`accounts.${index}.account`}
|
||||
label={_("Account")}
|
||||
rules={{
|
||||
required: _("Account is required"),
|
||||
// onChange: (event) => {
|
||||
// onAccountChange(event.target.value, index)
|
||||
// }
|
||||
}}
|
||||
buttonClassName="min-w-64"
|
||||
isRequired
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
{/* <TableCell className="align-top">
|
||||
<LinkFormField
|
||||
doctype="Cost Center"
|
||||
name={`accounts.${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={`accounts.${index}.user_remark`}
|
||||
label={_("Remarks")}
|
||||
inputProps={{
|
||||
placeholder: _("e.g. Bank Charges"),
|
||||
className: 'min-w-64',
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn("text-end align-top", index === fields.length - 1 ? "cursor-not-allowed" : "")}
|
||||
title={index === fields.length - 1 ? _("This is the last row. It will be auto populated based on the bank transaction.") : ""}>
|
||||
<DataField
|
||||
name={`accounts.${index}.debit`}
|
||||
label={_("Debit")}
|
||||
disabled={index === fields.length - 1}
|
||||
inputProps={{
|
||||
className: 'text-end',
|
||||
placeholder: _("0.00"),
|
||||
disabled: index === fields.length - 1
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn("text-end align-top", index === fields.length - 1 ? "cursor-not-allowed" : "")}
|
||||
title={index === fields.length - 1 ? _("This is the last row. It will be auto populated based on the bank transaction.") : ""}>
|
||||
<DataField
|
||||
name={`accounts.${index}.credit`}
|
||||
label={_("Credit")}
|
||||
disabled={index === fields.length - 1}
|
||||
inputProps={{
|
||||
className: 'text-end',
|
||||
placeholder: _("0.00"),
|
||||
disabled: index === fields.length - 1
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<H4 className="text-base text-ink-gray-7">{_("Help")}</H4>
|
||||
|
||||
<Paragraph className="text-p-sm">{(_("You can set up the rule to split the transaction across multiple accounts."))}
|
||||
<br />{_("You can also add credit or debit values to pre-fill - these support both static values (like 200) or formulas (like transaction_amount * 0.25).")}
|
||||
<br />
|
||||
<br />
|
||||
<span className="font-medium">{_("Example")}:</span>
|
||||
<br />
|
||||
<span className="font-numeric text-sm">
|
||||
transaction_amount * 0.25
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
{_("In this case, the amount will be calculated as 25% of the transaction amount. If the transaction amount is 200, then this will be calculated as 200 * 0.25 = 50.")}
|
||||
</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
const PartyRowField = ({ index, onChange }: { index: number, onChange: (value: string, index: number) => void }) => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `accounts.${index}.party_type`
|
||||
})
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`accounts.${index}.party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
className: 'rounded-s-none border-s-0 min-w-64'
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`accounts.${index}.party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange: (event) => {
|
||||
onChange(event.target.value, index)
|
||||
},
|
||||
}}
|
||||
hideLabel
|
||||
buttonClassName="rounded-s-none border-s-0 min-w-64"
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ArrowDownRight, ArrowUpRight, Calendar } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { UnreconciledTransaction, useGetBankAccounts } from './utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import _ from '@/lib/translate'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
type Props = {
|
||||
transaction: UnreconciledTransaction,
|
||||
showAccount?: boolean,
|
||||
account?: string
|
||||
}
|
||||
|
||||
const SelectedTransactionDetails = ({ transaction, showAccount = false, account }: Props) => {
|
||||
|
||||
const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (transaction.bank_account) {
|
||||
return banks?.find((bank) => bank.name === transaction.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [transaction.bank_account, banks])
|
||||
|
||||
const amount = transaction.withdrawal ? transaction.withdrawal : transaction.deposit
|
||||
|
||||
const currency = transaction.currency || getCompanyCurrency(transaction.company ?? '')
|
||||
|
||||
return (
|
||||
<Card className='py-4'>
|
||||
<CardContent className='px-4'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex justify-between'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<BankLogo bank={bank} iconSize='30px' imageClassName='h-10 max-w-20' />
|
||||
<span className='font-medium text-sm'>{transaction.bank_account}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(transaction.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col 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 ? _('Spent') : _('Received')}</span>
|
||||
</div>
|
||||
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
||||
{transaction.unallocated_amount && transaction.unallocated_amount !== amount ? <span className='text-ink-gray-5'>{_("Unallocated")}: {formatCurrency(transaction.unallocated_amount)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm'>{transaction.description}</span>
|
||||
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
|
||||
{showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardContent >
|
||||
</Card >
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectedTransactionDetails
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import _ from '@/lib/translate'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { ArrowDownRight, ArrowUpRight } from 'lucide-react'
|
||||
|
||||
const SelectedTransactionsTable = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const transactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{_("Date")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{_("Description")}
|
||||
</TableHead>
|
||||
<TableHead className="text-end">
|
||||
{_("Amount")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.name}>
|
||||
<TableCell>{formatDate(transaction.date)}</TableCell>
|
||||
<TableCell className="max-w-96 text-ellipsis overflow-hidden" title={transaction.description}>{transaction.description}</TableCell>
|
||||
<TableCell className="text-end flex items-center justify-end gap-1">
|
||||
{transaction.withdrawal && transaction.withdrawal > 0 ? <ArrowUpRight className="w-4 h-4 text-ink-red-3" /> : <ArrowDownRight className="w-4 h-4 text-ink-green-3" />}
|
||||
<span className="font-numeric font-medium">
|
||||
{formatCurrency(transaction.unallocated_amount, transaction.currency ?? '')}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectedTransactionsTable
|
||||
@@ -0,0 +1,555 @@
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { useForm, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { H4 } from '@/components/ui/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Form } from '@/components/ui/form'
|
||||
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
|
||||
import SelectedTransactionsTable from './SelectedTransactionsTable'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { useContext, useMemo, useState } from 'react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone'
|
||||
import FileUploadBanner from '@/components/common/FileUploadBanner'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
const TransferModal = () => {
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BankAccount } from "@/types/Accounts/BankAccount";
|
||||
import { getDatesForTimePeriod } from "@/lib/date";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { atomFamily } from 'jotai-family'
|
||||
import { UnreconciledTransaction } from "./utils";
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction";
|
||||
import { PaymentEntry } from "@/types/Accounts/PaymentEntry";
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry";
|
||||
|
||||
export interface SelectedBank extends Pick<BankAccount, 'name' | 'bank' | 'is_credit_card' | 'company' | 'account_name' | 'bank_account_no' | 'account' | 'account_type' | 'integration_id' | 'is_default' | 'last_integration_date'> {
|
||||
logo?: string,
|
||||
logoDark?: string,
|
||||
darkModeInvert?: boolean,
|
||||
logoClassName?: string,
|
||||
account_currency?: string
|
||||
}
|
||||
export const selectedBankAccountAtom = atom<SelectedBank | null>(null)
|
||||
|
||||
export const bankRecDateAtom = atomWithStorage<{ fromDate: string, toDate: string }>("bank-rec-date", {
|
||||
fromDate: getDatesForTimePeriod('This Month').fromDate,
|
||||
toDate: getDatesForTimePeriod('This Month').toDate
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const bankRecClosingBalanceAtom = atomFamily((_id: string) => {
|
||||
return atom<{ value: number, stringValue: string | number | undefined }>({
|
||||
value: 0,
|
||||
stringValue: '0.00'
|
||||
})
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const bankRecSelectedTransactionAtom = atomFamily((_id: string) => {
|
||||
return atom<UnreconciledTransaction[]>([])
|
||||
})
|
||||
|
||||
/** Action Modals */
|
||||
export const bankRecTransferModalAtom = atom(false)
|
||||
export const bankRecRecordPaymentModalAtom = atom(false)
|
||||
export const bankRecRecordJournalEntryModalAtom = atom(false)
|
||||
|
||||
export const bankRecUnreconcileModalAtom = atom<string>('')
|
||||
|
||||
export const bankRecMatchFilters = atomWithStorage<string[]>('bank-rec-match-filters', ['payment_entry', 'journal_entry'])
|
||||
|
||||
export const bankRecSearchText = atom<string>('')
|
||||
export const bankRecAmountFilter = atom<{ value: number, stringValue?: string | number }>({
|
||||
value: 0,
|
||||
stringValue: '0.00'
|
||||
})
|
||||
export const bankRecTransactionTypeFilter = atom<string>('All')
|
||||
|
||||
export interface ActionLog {
|
||||
type: 'match' | 'payment' | 'transfer' | 'bank_entry'
|
||||
isBulk: boolean
|
||||
timestamp: number,
|
||||
items: ActionLogItem[],
|
||||
bulkCommonData?: {
|
||||
party_type?: string,
|
||||
party?: string,
|
||||
account?: string,
|
||||
bank_account?: string,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActionLogItem {
|
||||
bankTransaction: BankTransaction,
|
||||
voucher: {
|
||||
reference_doctype: string,
|
||||
reference_name: string,
|
||||
reference_no?: string,
|
||||
reference_date?: string,
|
||||
posting_date: string,
|
||||
doc?: PaymentEntry | JournalEntry
|
||||
},
|
||||
}
|
||||
|
||||
const actionLogStorage = createJSONStorage<ActionLog[]>(() => sessionStorage)
|
||||
|
||||
export const bankRecActionLog = atomWithStorage<ActionLog[]>('bank-rec-action-log', [], actionLogStorage, {
|
||||
getOnInit: true,
|
||||
})
|
||||
397
banking/src/components/features/BankReconciliation/logos.ts
Normal file
397
banking/src/components/features/BankReconciliation/logos.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[], logoDark?: string, darkModeInvert?: boolean, logoClassName?: string }[] = [
|
||||
// United States + International
|
||||
{
|
||||
keywords: ['American Express', 'Amex'],
|
||||
logo: 'Amex.svg',
|
||||
locale: ['Global', 'United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of America', 'BOA'],
|
||||
logo: 'Bank_of_America.png',
|
||||
darkModeInvert: true,
|
||||
locale: ['United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Barclays'],
|
||||
logo: 'Barclays.svg',
|
||||
locale: ['Global', 'United Kingdom'],
|
||||
logoClassName: 'h-12',
|
||||
},
|
||||
{
|
||||
keywords: ['BNP Paribas'],
|
||||
logo: 'BNP_Paribas.svg',
|
||||
logoDark: 'BNP_Paribas-Dark.svg',
|
||||
locale: ['Global', 'France'],
|
||||
logoClassName: 'max-w-24'
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of New York Mellon', 'BNY Mellon', 'BNY'],
|
||||
logo: 'BNY_Mellon.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
logoDark: 'BNY_Mellon-Dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Capital One'],
|
||||
logo: 'Capital_One.png',
|
||||
locale: ['United States'],
|
||||
darkModeInvert: true
|
||||
},
|
||||
{
|
||||
keywords: ['Charles Schwab', 'Schwab'],
|
||||
logo: 'Charles_Schwab.svg',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['Chase'],
|
||||
logo: 'chase.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
logoDark: 'chase-Dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Citi', 'Citibank', 'Citi Group', 'Citi Financial Services'],
|
||||
logo: 'Citi.svg',
|
||||
locale: ['Global', 'United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Deutsche Bank'],
|
||||
logo: 'Deutsche_Bank.svg',
|
||||
locale: ['Global', 'Germany'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['Goldman Sachs'],
|
||||
logo: 'Goldman_Sachs.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['HSBC'],
|
||||
logo: 'HSBC.svg',
|
||||
locale: ['Global', 'United Kingdom'],
|
||||
logoDark: 'HSBC-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['JPMorgan Chase', 'JPMorgan', 'JP Morgan', 'JP Morgan Chase', 'JPMorgan Chase & Co', 'JPM', 'JPMC'],
|
||||
logo: 'jpmc.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['Morgan Stanley'],
|
||||
logo: 'Morgan_Stanley.png',
|
||||
locale: ['Global', 'United States'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['PNC', 'PNC Financial Services Group', 'PNC Financial Services', 'Pittsburgh National Corporation'],
|
||||
logo: 'PNC.png',
|
||||
locale: ['United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Santander'],
|
||||
logo: 'Santander.svg',
|
||||
locale: ['Global']
|
||||
},
|
||||
{
|
||||
keywords: ['TD Bank', 'Toronto Dominion Bank'],
|
||||
logo: 'Toronto_Dominion_Bank.png',
|
||||
locale: ['Canada']
|
||||
},
|
||||
{
|
||||
keywords: ['Truist'],
|
||||
logo: 'Truist.svg',
|
||||
locale: ['United States'],
|
||||
darkModeInvert: true,
|
||||
logoClassName: 'h-8'
|
||||
},
|
||||
{
|
||||
keywords: ['UBS'],
|
||||
logo: 'UBS.svg',
|
||||
locale: ['Global', 'Switzerland'],
|
||||
logoDark: 'UBS-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['US Bank', 'USBank', 'U.S. Bank', 'U.S. Bancorp'],
|
||||
logo: 'USBank.svg',
|
||||
locale: ['United States'],
|
||||
logoDark: 'USBank-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Wells Fargo', 'Wells Fargo'],
|
||||
logo: 'Wells_Fargo.svg',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['OakStar', 'Oakstar', 'Oakstar'],
|
||||
logo: 'Oakstar.png',
|
||||
logoDark: 'Oakstar-dark.webp',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['PlainsCapital', 'Plains Capital'],
|
||||
logo: 'PlainsCapitalBank.png',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["Standard Chartered"],
|
||||
logo: 'Standard_Chartered.png',
|
||||
logoDark: 'Standard_Chartered-dark.png',
|
||||
locale: ['Global'],
|
||||
},
|
||||
// India
|
||||
{
|
||||
keywords: ['HDFC Bank', 'HDFC'],
|
||||
logo: 'HDFC.svg',
|
||||
locale: ['India'],
|
||||
},
|
||||
{
|
||||
keywords: ['ICICI Bank', 'ICICI'],
|
||||
logo: 'ICICI.svg',
|
||||
logoDark: 'ICICI-dark.svg',
|
||||
locale: ['India'],
|
||||
},
|
||||
{
|
||||
keywords: ['SBI', 'State Bank of India'],
|
||||
logo: 'State_Bank_of_India.svg',
|
||||
logoDark: 'State_bank_of_India-Dark.svg',
|
||||
locale: ['India'],
|
||||
logoClassName: 'h-4.5'
|
||||
},
|
||||
{
|
||||
keywords: ['Punjab National Bank', 'PNB'],
|
||||
logo: 'Punjab_National_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Union Bank of India', 'Union Bank'],
|
||||
logo: 'Union_Bank_of_India.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Yes Bank', 'Yes'],
|
||||
logo: 'Yes_Bank.svg',
|
||||
locale: ['India'],
|
||||
logoDark: 'Yes_Bank-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['RBL Bank', 'RBL'],
|
||||
logo: 'RBL_Bank.svg',
|
||||
locale: ['India'],
|
||||
logoDark: 'RBL_Bank-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Axis Bank', 'Axis'],
|
||||
logo: 'Axis_Bank.svg',
|
||||
locale: ['India'],
|
||||
darkModeInvert: true
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of Baroda', 'BOB'],
|
||||
logo: 'Bank_of_Baroda.svg',
|
||||
locale: ['India', 'Kenya'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of India', 'BOI'],
|
||||
logo: 'Bank_of_India.png',
|
||||
locale: ['India'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of Maharashtra', 'BOM'],
|
||||
logo: 'Bank_of_Maharashtra.png',
|
||||
locale: ['India'],
|
||||
logoClassName: 'min-w-24'
|
||||
},
|
||||
{
|
||||
keywords: ['Kotak Mahindra Bank', 'Kotak'],
|
||||
logo: 'Kotak_Mahindra.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['IndusInd Bank', 'IndusInd'],
|
||||
logo: 'IndusInd_Bank.svg',
|
||||
locale: ['India'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['IDBI Bank', 'IDBI'],
|
||||
logo: 'IDBI_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['IDFC First Bank', 'IDFC First'],
|
||||
logo: 'IDFC_First_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Federal Bank'],
|
||||
logo: 'Federal_Bank.png',
|
||||
logoDark: 'Federal_Bank-dark.png',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Fi Bank'],
|
||||
logo: 'Fi_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['RazorpayX', 'Razorpay'],
|
||||
logo: 'Razorpay.svg',
|
||||
logoDark: 'Razorpay-dark.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Revolut'],
|
||||
logo: 'Revolut.png',
|
||||
locale: ['Global'],
|
||||
darkModeInvert: true
|
||||
},
|
||||
{
|
||||
keywords: ['Starling Bank'],
|
||||
logo: 'Starling_Bank.png',
|
||||
logoDark: 'Starling_Bank-dark.png',
|
||||
locale: ['Global', 'UK'],
|
||||
logoClassName: 'h-10'
|
||||
},
|
||||
// Australia and New Zealand
|
||||
{
|
||||
keywords: ["Commonwealth Bank", "CBA"],
|
||||
logo: "Commonwealth_Bank.svg",
|
||||
locale: ['Australia', 'New Zealand'],
|
||||
},
|
||||
{
|
||||
keywords: ["Airwallex"],
|
||||
logo: "Airwallex.png",
|
||||
logoDark: "Airwallex-dark.png",
|
||||
locale: ['Global']
|
||||
},
|
||||
{
|
||||
keywords: ["Judo Bank"],
|
||||
logo: "Judo_Bank.svg",
|
||||
logoDark: "Judo_Bank-dark.svg",
|
||||
locale: ['Australia', 'New Zealand']
|
||||
},
|
||||
{
|
||||
keywords: ["Alpha"], // This might conflict with Alpha Bank in Greece
|
||||
logo: "Alpha_Bank.svg",
|
||||
darkModeInvert: true,
|
||||
logoClassName: 'h-4.5',
|
||||
locale: ['Australia', 'New Zealand']
|
||||
},
|
||||
{
|
||||
keywords: ["Australian Tax Office", "Australian Taxation Office"],
|
||||
logo: "Australian_Tax_Office.png",
|
||||
darkModeInvert: true,
|
||||
locale: ['Australia']
|
||||
},
|
||||
{
|
||||
keywords: ["Westpac"],
|
||||
logo: "Westpac.svg",
|
||||
locale: ['Australia']
|
||||
},
|
||||
{
|
||||
keywords: ["ANZ", "ANZ Bank", "Australia and New Zealand Banking Group"],
|
||||
logo: "ANZ.png",
|
||||
locale: ['Australia', 'New Zealand']
|
||||
},
|
||||
{
|
||||
keywords: ["Macquarie Group", "Macquarie Bank"],
|
||||
logo: "Macquarie.svg",
|
||||
darkModeInvert: true,
|
||||
locale: ['Australia']
|
||||
},
|
||||
// Nicaragua
|
||||
{
|
||||
keywords: ["Banco Atlantida", "Banco Atlántida"],
|
||||
logo: "Banco_Atlantida.png",
|
||||
locale: ['Nicaragua']
|
||||
},
|
||||
{
|
||||
keywords: ["Banco de Finanzas"],
|
||||
logo: "Banco_de_Finanzas.svg",
|
||||
locale: ['Nicaragua'],
|
||||
logoClassName: 'h-4.5'
|
||||
},
|
||||
{
|
||||
keywords: ["Avanz"],
|
||||
logo: "Avanz.svg",
|
||||
logoDark: "Avanz-dark.svg",
|
||||
locale: ['Nicaragua'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["Ficohsa"],
|
||||
logo: "Ficohsa.svg",
|
||||
locale: ['Nicaragua']
|
||||
},
|
||||
{
|
||||
keywords: ["BAC", "BAC Credomatic"],
|
||||
logo: "BAC_Credomatic.svg",
|
||||
locale: ['Nicaragua'],
|
||||
logoClassName: 'h-4.5'
|
||||
},
|
||||
{
|
||||
keywords: ["Banco Lafise"],
|
||||
logo: "Banco_Lafise.png",
|
||||
darkModeInvert: true,
|
||||
locale: ['Nicaragua']
|
||||
},
|
||||
// German
|
||||
{
|
||||
keywords: ["Sparkasse"],
|
||||
logo: "Sparkasse.png",
|
||||
locale: ['Germany']
|
||||
},
|
||||
{
|
||||
keywords: ["Volksbank", "Raiffeisenbank", "VR-Bank"],
|
||||
logo: "Volksbanken_Raiffeisenbanken.svg",
|
||||
locale: ['Germany'],
|
||||
logoClassName: 'min-w-32'
|
||||
},
|
||||
// Kenya
|
||||
{
|
||||
keywords: ["KCB Bank", "KCB"],
|
||||
logo: "KCB_Bank_Kenya.png",
|
||||
locale: ['Kenya']
|
||||
},
|
||||
{
|
||||
keywords: ["Equity Bank"],
|
||||
logo: "Equity_Bank.png",
|
||||
logoDark: "Equity_Bank-dark.png",
|
||||
locale: ['Kenya'],
|
||||
},
|
||||
{
|
||||
keywords: ["I&M"],
|
||||
logo: "I&M.png",
|
||||
locale: ['Kenya']
|
||||
},
|
||||
{
|
||||
keywords: ["ABSA"],
|
||||
logo: "ABSA.png",
|
||||
locale: ['Kenya'],
|
||||
darkModeInvert: true,
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["Stanbic"],
|
||||
logo: "Stanbic.png",
|
||||
locale: ['Kenya'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["DTB", "Diamond Trust Bank"],
|
||||
logo: "Diamond_Trust_Bank.png",
|
||||
locale: ['Kenya']
|
||||
},
|
||||
{
|
||||
keywords: ["Prime Bank"],
|
||||
logo: "Prime_Bank.png",
|
||||
locale: ['Kenya'],
|
||||
logoClassName: 'max-w-28'
|
||||
}
|
||||
]
|
||||
457
banking/src/components/features/BankReconciliation/utils.ts
Normal file
457
banking/src/components/features/BankReconciliation/utils.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { ActionLog, bankRecActionLog, bankRecAmountFilter, bankRecDateAtom, bankRecMatchFilters, bankRecSearchText, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useMemo } from 'react'
|
||||
import { SWRConfiguration, useFrappeGetCall, useFrappeGetDoc, useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { BankAccount } from '@/types/Accounts/BankAccount'
|
||||
import dayjs from 'dayjs'
|
||||
import { toast } from 'sonner'
|
||||
import { BANK_LOGOS } from './logos'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import _ from '@/lib/translate'
|
||||
import { BankTransactionRule } from '@/types/Accounts/BankTransactionRule'
|
||||
import { useRef } from 'react'
|
||||
import type { DebouncedState } from 'usehooks-ts'
|
||||
import { useDebounceCallback } from 'usehooks-ts'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
export const useGetAccountOpeningBalance = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const args = useMemo(() => {
|
||||
|
||||
return {
|
||||
bank_account: bankAccount?.name,
|
||||
company: companyID,
|
||||
till_date: dayjs(dates.fromDate).subtract(1, 'days').format('YYYY-MM-DD'),
|
||||
}
|
||||
|
||||
}, [companyID, bankAccount?.name, dates.fromDate])
|
||||
|
||||
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args, undefined, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetAccountClosingBalance = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const args = useMemo(() => {
|
||||
|
||||
return {
|
||||
bank_account: bankAccount?.name,
|
||||
company: companyID,
|
||||
till_date: dates.toDate,
|
||||
}
|
||||
|
||||
}, [companyID, bankAccount?.name, dates.toDate])
|
||||
|
||||
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args,
|
||||
`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`,
|
||||
{
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch the closing balance set in the database for the given bank and date
|
||||
*/
|
||||
export const useGetAccountClosingBalanceAsPerStatement = (swrConfig: SWRConfiguration = {}) => {
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
return useFrappeGetCall<{ message: { balance: number, date?: string } }>("erpnext.accounts.doctype.bank_account.bank_account.get_closing_balance_as_per_statement", {
|
||||
bank_account: bankAccount?.name,
|
||||
date: dates.toDate
|
||||
}, `bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${dates.toDate}`, {
|
||||
revalidateOnFocus: false,
|
||||
...swrConfig
|
||||
})
|
||||
}
|
||||
|
||||
export type UnreconciledTransaction = Pick<BankTransaction, 'name' | 'matched_transaction_rule' | 'date' | 'withdrawal' | 'deposit' | 'currency' | 'description' | 'status' | 'transaction_type' | 'reference_number' | 'party_type' | 'party' | 'bank_account' | 'company' | 'unallocated_amount'>
|
||||
|
||||
|
||||
export const useGetUnreconciledTransactions = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
return useFrappeGetCall<{ message: UnreconciledTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
|
||||
bank_account: bankAccount?.name,
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate
|
||||
}, bankAccount ? `bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
})
|
||||
}
|
||||
|
||||
export interface LinkedPayment {
|
||||
rank: number,
|
||||
doctype: string,
|
||||
name: string,
|
||||
paid_amount: number,
|
||||
reference_no: string,
|
||||
reference_date: string,
|
||||
posting_date: string,
|
||||
party_type?: string,
|
||||
party?: string,
|
||||
currency: string
|
||||
}
|
||||
|
||||
export const useGetBankTransactions = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
return useFrappeGetCall<{ message: BankTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
|
||||
bank_account: bankAccount?.name,
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate,
|
||||
all_transactions: true
|
||||
}, bankAccount ? `bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null)
|
||||
}
|
||||
|
||||
|
||||
export const useGetVouchersForTransaction = (transaction: UnreconciledTransaction) => {
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
|
||||
return useFrappeGetCall<{ message: LinkedPayment[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_linked_payments', {
|
||||
bank_transaction_name: transaction.name,
|
||||
document_types: matchFilters ?? ['payment_entry', 'journal_entry'],
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate,
|
||||
filter_by_reference_date: 0
|
||||
}, `bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Common hook to refresh the unreconciled transactions list after a transaction is reconciled
|
||||
* @returns function to call to refresh the unreconciled transactions list AFTER the operation is done
|
||||
*/
|
||||
export const useRefreshUnreconciledTransactions = () => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const searchString = useAtomValue(bankRecSearchText)
|
||||
const typeFilter = useAtomValue(bankRecTransactionTypeFilter)
|
||||
const amountFilter = useAtomValue(bankRecAmountFilter)
|
||||
|
||||
const { data: unreconciledTransactions } = useGetUnreconciledTransactions()
|
||||
|
||||
/**
|
||||
* This function should be called after a transaction is reconciled
|
||||
* It will get the next unreconciled transaction and select it
|
||||
* And then refresh the balance + unreconciled transactions list
|
||||
*/
|
||||
const onReconcileTransaction = (transaction: UnreconciledTransaction, updatedTransaction?: BankTransaction) => {
|
||||
|
||||
// If the updated transaction has an unallocated amount of 0, then we need to select the next unreconciled transaction
|
||||
if (updatedTransaction && updatedTransaction?.unallocated_amount !== 0) {
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
// Update the matching vouchers for the selected transaction
|
||||
mutate(`bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
|
||||
return
|
||||
}
|
||||
|
||||
// From unreconciled transactions list, first apply the filters based on the search criteria and other filters
|
||||
|
||||
const searchIndex = unreconciledTransactions ? new Fuse(unreconciledTransactions.message, {
|
||||
keys: ['description', 'reference_number'],
|
||||
threshold: 0.5,
|
||||
includeScore: true
|
||||
}) : null
|
||||
|
||||
const results = getSearchResults(searchIndex, searchString, typeFilter, amountFilter.value, unreconciledTransactions?.message)
|
||||
|
||||
const currentIndex = results.findIndex(t => t.name === transaction.name)
|
||||
let nextTransaction = null
|
||||
|
||||
if (currentIndex !== -1) {
|
||||
// Check if there is a next transaction
|
||||
if (currentIndex < (results.length || 0) - 1) {
|
||||
nextTransaction = results[currentIndex + 1]
|
||||
}
|
||||
}
|
||||
|
||||
// We need to select the next unreconciled transaction for a better UX
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
.then(res => {
|
||||
if (nextTransaction) {
|
||||
// Check if next transaction is there in the response
|
||||
const nextTransactionObj = res?.message.find((t: UnreconciledTransaction) => t.name === nextTransaction.name)
|
||||
if (nextTransactionObj) {
|
||||
setSelectedTransaction([nextTransactionObj])
|
||||
} else {
|
||||
// If the next transaction is not there in the response, we need to clear the selection
|
||||
setSelectedTransaction([])
|
||||
}
|
||||
} else {
|
||||
// If there is no next transaction, we need to clear the selection
|
||||
setSelectedTransaction([])
|
||||
}
|
||||
})
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
}
|
||||
|
||||
return onReconcileTransaction
|
||||
|
||||
}
|
||||
|
||||
export const useReconcileTransaction = () => {
|
||||
|
||||
const { call, loading } = useFrappePostCall<{ message: BankTransaction }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers')
|
||||
|
||||
const onReconcileTransaction = useRefreshUnreconciledTransactions()
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const reconcileTransaction = (transaction: UnreconciledTransaction, voucher: LinkedPayment) => {
|
||||
|
||||
call({
|
||||
bank_transaction_name: transaction.name,
|
||||
vouchers: JSON.stringify([{
|
||||
"payment_doctype": voucher.doctype,
|
||||
"payment_name": voucher.name,
|
||||
"amount": voucher.paid_amount
|
||||
}])
|
||||
}).then((res) => {
|
||||
addToActionLog({
|
||||
type: 'match',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: false,
|
||||
items: [
|
||||
{
|
||||
bankTransaction: res.message,
|
||||
voucher: {
|
||||
reference_doctype: voucher.doctype,
|
||||
reference_name: voucher.name,
|
||||
reference_no: voucher.reference_no,
|
||||
reference_date: voucher.reference_date,
|
||||
posting_date: voucher.posting_date,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
onReconcileTransaction(transaction, res.message)
|
||||
toast.success(_("Reconciled"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(transaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
toast.error(_("Error"), {
|
||||
duration: 5000,
|
||||
description: getErrorMessage(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { reconcileTransaction, loading }
|
||||
|
||||
}
|
||||
|
||||
interface BankAccountWithCurrency extends Pick<BankAccount, 'name' | 'bank' | 'account_name' | 'is_credit_card' | 'company' | 'account' | 'account_type' | 'account_subtype' | 'bank_account_no' | 'last_integration_date'> {
|
||||
account_currency?: string
|
||||
}
|
||||
|
||||
type BankLogoEntry = (typeof BANK_LOGOS)[number]
|
||||
|
||||
/** Prefer the longest keyword match so short tokens (e.g. "anz" in "finanzas") do not beat full bank names. */
|
||||
function findBankLogoForName(bankName: string | undefined | null): BankLogoEntry | undefined {
|
||||
if (!bankName) return undefined
|
||||
const haystack = bankName.toLowerCase()
|
||||
let best: BankLogoEntry | undefined
|
||||
let bestKeywordLen = 0
|
||||
for (const entry of BANK_LOGOS) {
|
||||
for (const keyword of entry.keywords) {
|
||||
const needle = keyword.toLowerCase()
|
||||
if (needle.length === 0) continue
|
||||
if (haystack.includes(needle) && needle.length > bestKeywordLen) {
|
||||
bestKeywordLen = needle.length
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export const useGetBankAccounts = (onSuccess?: (data?: Omit<SelectedBank, 'logo'>[]) => void, filterFn?: (bank: SelectedBank) => boolean) => {
|
||||
|
||||
const company = useCurrentCompany()
|
||||
|
||||
const { data, isLoading, error } = useFrappeGetCall<{ message: BankAccountWithCurrency[] }>('erpnext.accounts.doctype.bank_account.bank_account.get_list', {
|
||||
company: company
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
onSuccess: (data) => {
|
||||
onSuccess?.(data?.message)
|
||||
}
|
||||
})
|
||||
|
||||
const banks = useMemo(() => {
|
||||
// Match the bank account to the logo
|
||||
const banksWithLogos = data?.message.map((bank) => {
|
||||
const logo = findBankLogoForName(bank.bank)
|
||||
return {
|
||||
...bank,
|
||||
logo: logo?.logo,
|
||||
logoDark: logo?.logoDark,
|
||||
darkModeInvert: logo?.darkModeInvert,
|
||||
logoClassName: logo?.logoClassName
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
if (filterFn) {
|
||||
return banksWithLogos.filter(filterFn)
|
||||
}
|
||||
|
||||
return banksWithLogos
|
||||
}, [data, filterFn])
|
||||
|
||||
return {
|
||||
banks,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const useIsTransactionWithdrawal = (transaction: UnreconciledTransaction) => {
|
||||
return useMemo(() => {
|
||||
const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0
|
||||
const isDeposit = transaction.deposit && transaction.deposit > 0
|
||||
|
||||
return {
|
||||
amount: isWithdrawal ? transaction.withdrawal : transaction.deposit,
|
||||
isWithdrawal,
|
||||
isDeposit
|
||||
}
|
||||
}, [transaction])
|
||||
}
|
||||
|
||||
export const useGetRuleForTransaction = (transaction: UnreconciledTransaction) => {
|
||||
|
||||
return useFrappeGetDoc<BankTransactionRule>('Bank Transaction Rule', transaction.matched_transaction_rule,
|
||||
transaction.matched_transaction_rule ? undefined : null, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Hook to handle the search input while maintaining debouncing and global state. */
|
||||
export function useTransactionSearch(): [string, DebouncedState<(value: string) => void>] {
|
||||
const delay = 500
|
||||
const unwrappedInitialValue = ''
|
||||
const eq = (left: string, right: string) => left === right
|
||||
const [debouncedValue, setDebouncedValue] = useAtom(bankRecSearchText)
|
||||
const previousValueRef = useRef<string | undefined>(unwrappedInitialValue)
|
||||
|
||||
const updateDebouncedValue = useDebounceCallback(
|
||||
setDebouncedValue,
|
||||
delay,
|
||||
)
|
||||
|
||||
// Update the debounced value if the initial value changes
|
||||
if (!eq(previousValueRef.current as string, unwrappedInitialValue)) {
|
||||
updateDebouncedValue(unwrappedInitialValue)
|
||||
previousValueRef.current = unwrappedInitialValue
|
||||
}
|
||||
|
||||
return [debouncedValue, updateDebouncedValue]
|
||||
}
|
||||
|
||||
/** Utility function to get the search results based on the search index, search string, type filter, amount filter and unreconciled transactions */
|
||||
export const getSearchResults = (
|
||||
/** Fuse index of the unreconciled transactions */
|
||||
searchIndex: Fuse<UnreconciledTransaction> | null,
|
||||
/** Search string */
|
||||
search: string,
|
||||
/** Type filter */
|
||||
typeFilter: string,
|
||||
/** Amount filter */
|
||||
amountFilter: number,
|
||||
/** Unreconciled transactions */
|
||||
unreconciledTransactions?: UnreconciledTransaction[]) => {
|
||||
|
||||
let r = []
|
||||
if (!searchIndex || !search) {
|
||||
r = unreconciledTransactions ?? []
|
||||
} else {
|
||||
r = searchIndex.search(search).map((result) => result.item)
|
||||
}
|
||||
|
||||
if (typeFilter !== 'All') {
|
||||
r = r.filter((transaction) => {
|
||||
if (typeFilter === 'Debits') {
|
||||
return transaction.withdrawal && transaction.withdrawal > 0
|
||||
}
|
||||
if (typeFilter === 'Credits') {
|
||||
return transaction.deposit && transaction.deposit > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (amountFilter > 0) {
|
||||
r = r.filter((transaction) => {
|
||||
if (transaction.withdrawal && transaction.withdrawal > 0) {
|
||||
return transaction.withdrawal === amountFilter
|
||||
}
|
||||
if (transaction.deposit && transaction.deposit > 0) {
|
||||
return transaction.deposit === amountFilter
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
export const useUpdateActionLog = () => {
|
||||
|
||||
const setActionLog = useSetAtom(bankRecActionLog)
|
||||
|
||||
const addToActionLog = (action: ActionLog) => {
|
||||
// Store at max 100 actions
|
||||
setActionLog((prev) => {
|
||||
const newActions = [action, ...prev]
|
||||
if (newActions.length > 100) {
|
||||
return newActions.slice(0, 100)
|
||||
}
|
||||
return newActions
|
||||
})
|
||||
}
|
||||
|
||||
return addToActionLog
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import CSVRawDataPreview from './CSVRawDataPreview'
|
||||
import StatementDetails from './StatementDetails'
|
||||
import _ from '@/lib/translate'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
<div className="w-[50%] p-4 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<CSVRawDataPreview data={data.message} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CSVImport
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import _ from "@/lib/translate"
|
||||
import { GetStatementDetailsResponse } from "../import_utils"
|
||||
import { useMemo } from "react"
|
||||
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
|
||||
|
||||
|
||||
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
|
||||
|
||||
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
|
||||
|
||||
const col_map: Record<string, number> = {}
|
||||
|
||||
data.doc.column_mapping?.forEach(col => {
|
||||
if (col.maps_to && col.maps_to !== "Do not import") {
|
||||
col_map[col.maps_to] = col.index;
|
||||
}
|
||||
})
|
||||
|
||||
return col_map
|
||||
|
||||
}, [data])
|
||||
|
||||
const validColumns = Object.values(column_mapping)
|
||||
|
||||
// Reverse the column mapping to get a map of column index to variable name
|
||||
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
|
||||
|
||||
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
|
||||
return (
|
||||
<Table containerClassName="rounded-none">
|
||||
<TableBody>
|
||||
{data.raw_data.map((row, index) => {
|
||||
|
||||
const isHeaderRow = index === data.doc.detected_header_index;
|
||||
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
|
||||
|
||||
return <TableRow key={index}
|
||||
title={isHeaderRow ? "Header Row" : ""}
|
||||
className={cn({
|
||||
// "bg-yellow-100": isHeaderRow,
|
||||
// "hover:bg-yellow-100": isHeaderRow,
|
||||
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
|
||||
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
|
||||
})}>
|
||||
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
|
||||
{index + 1}
|
||||
</TableHead> :
|
||||
<TableCell className="text-center px-1 py-0.5">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
}
|
||||
{row.map((cell, cellIndex) => {
|
||||
|
||||
const isValidColumn = validColumns.includes(cellIndex);
|
||||
const columnType = columnIndexMap[cellIndex];
|
||||
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
|
||||
|
||||
if (isHeaderRow) {
|
||||
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
|
||||
)}>
|
||||
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
|
||||
"justify-end": isAmountColumn && isValidColumn
|
||||
})}>
|
||||
{columnType && <Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ColumnHeaderIcon columnType={columnType} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_(columnType)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
{cell}
|
||||
</div>
|
||||
</TableHead>
|
||||
} else {
|
||||
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
{
|
||||
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
|
||||
"text-ink-gray-5": !isValidColumn && isTransactionRow,
|
||||
}
|
||||
)} >
|
||||
<div className={cn("min-h-5 flex items-center text-xs px-1", {
|
||||
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
|
||||
})} title={cell}>
|
||||
{cell}
|
||||
</div>
|
||||
</TableCell>
|
||||
}
|
||||
}
|
||||
|
||||
)}
|
||||
</TableRow>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table >
|
||||
)
|
||||
}
|
||||
|
||||
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
|
||||
|
||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
|
||||
if (!columnType) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (columnType === 'Amount') {
|
||||
return <DollarSignIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Withdrawal') {
|
||||
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Deposit') {
|
||||
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Balance') {
|
||||
return <BanknoteIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Date') {
|
||||
return <CalendarIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Description') {
|
||||
return <FileTextIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Reference') {
|
||||
return <ReceiptIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Transaction Type') {
|
||||
return <ListIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Debit/Credit') {
|
||||
return <ArrowUpDownIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default CSVRawDataPreview
|
||||
@@ -0,0 +1,351 @@
|
||||
import _ from '@/lib/translate'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
import { flt, formatCurrency } from '@/lib/numbers'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { bankRecDateAtom } from '../../BankReconciliation/bankRecAtoms'
|
||||
import { AlertCircleIcon, ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, InfoIcon, Loader2Icon } from 'lucide-react'
|
||||
import { H2, H3, Paragraph } from '@/components/ui/typography'
|
||||
import { FileTypeIcon } from '@/components/ui/file-dropzone'
|
||||
import { getFileExtension } from '@/lib/file'
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
import { useGetBankAccounts } from '../../BankReconciliation/utils'
|
||||
import { BankStatementImportLog } from '@/types/Accounts/BankStatementImportLog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
|
||||
const parseDateFormat = (dateFormat: string) => {
|
||||
|
||||
const charMap = {
|
||||
"%d": "DD",
|
||||
"%m": "MM",
|
||||
"%Y": "YYYY",
|
||||
"%y": "YY",
|
||||
"%b": "MMM",
|
||||
"%B": "MMMM",
|
||||
}
|
||||
|
||||
let label = dateFormat
|
||||
|
||||
Object.keys(charMap).forEach((char) => {
|
||||
label = label.replace(char, charMap[char as keyof typeof charMap])
|
||||
})
|
||||
|
||||
return dateFormat
|
||||
|
||||
}
|
||||
|
||||
type Props = {
|
||||
data: GetStatementDetailsResponse,
|
||||
}
|
||||
|
||||
const StatementDetails = ({ data }: Props) => {
|
||||
const dateFormat = parseDateFormat(data.date_format)
|
||||
|
||||
const { call, loading, error } = useFrappePostCall<{ docs: BankStatementImportLog[] }>('run_doc_method')
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const setDates = useSetAtom(bankRecDateAtom)
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
const onImport = () => {
|
||||
|
||||
call({
|
||||
docs: data.doc,
|
||||
method: 'insert_transactions'
|
||||
}).then((response) => {
|
||||
const doc = response.docs ? response.docs[0] : undefined
|
||||
if (doc && doc.start_date && doc.end_date) {
|
||||
setDates({
|
||||
fromDate: doc.start_date,
|
||||
toDate: doc.end_date,
|
||||
})
|
||||
}
|
||||
toast.success(_("Bank statement imported."))
|
||||
navigate(`/`)
|
||||
}).catch(() => {
|
||||
toast.error(_("There was an error while importing the bank statement."))
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useFrappeEventListener("bank-rec-statement-import-progress", (event) => {
|
||||
setProgress(event.progress)
|
||||
})
|
||||
|
||||
const file_name = data.doc.file.split("/").pop() ?? ""
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
|
||||
return banks?.find((bank) => bank.name === data.doc.bank_account)
|
||||
|
||||
}, [data.doc.bank_account, banks])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link to="/statement-importer">
|
||||
{direction === 'ltr' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
|
||||
{_("Back")}
|
||||
</Link>
|
||||
</Button>
|
||||
{data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> :
|
||||
<Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'>
|
||||
{loading ? <Loader2Icon className='size-4 animate-spin' /> : null}
|
||||
{loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
}
|
||||
</div>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<H2 className='text-lg border-0 p-0'>{_("Statement Details")}</H2>
|
||||
<Paragraph className='text-p-sm'><span>
|
||||
{_("We've auto-detected the details of the statement file.")}
|
||||
</span><br />
|
||||
<span>
|
||||
{_("Please review the details below and click the 'Import' button to proceed.")}
|
||||
</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" />
|
||||
<span className='text-sm'>{_("Importing {0} transactions", [progress.toString()])}
|
||||
</span>
|
||||
</div>}
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>{_("Bank Account")}</TableHead>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BankLogo bank={bank} />
|
||||
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Statement File")}</TableHead>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileTypeIcon fileType={getFileExtension(file_name)} size='md' showBackground={false} />
|
||||
{file_name}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Transaction Dates")}</TableHead>
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Number of Transactions")}</TableHead>
|
||||
<TableCell>{data.doc.number_of_transactions}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Total Debits")}</TableHead>
|
||||
<TableCell><span className='font-numeric'>{formatCurrency(flt(data.doc.total_debits, 2), data.currency)}</span> <span className='text-ink-gray-5 font-sans'>({data.doc.total_debit_transactions} {data.doc.total_debit_transactions === 1 ? _("transaction") : _("transactions")})</span></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Total Credits")}</TableHead>
|
||||
<TableCell><span className='font-numeric'>{formatCurrency(flt(data.doc.total_credits, 2), data.currency)}</span> <span className='text-ink-gray-5 font-sans'>({data.doc.total_credit_transactions} {data.doc.total_credit_transactions === 1 ? _("transaction") : _("transactions")})</span></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Closing Balance as of {}", [formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableHead>
|
||||
<TableCell className='font-numeric'>{formatCurrency(flt(data.doc.closing_balance, 2), data.currency)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className='flex items-center gap-2'>
|
||||
{_("Detected Amount Format")} <Tooltip>
|
||||
<TooltipTrigger><InfoIcon size={16} /></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("The amount format detected in the statement file. This is used to parse the deposit and withdrawal values from each row.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableCell>{data.doc.detected_amount_format}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className='flex items-center gap-2'>
|
||||
{_("Detected Date Format")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger><InfoIcon size={16} /></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("The date format detected in the statement file. This is used to parse the date values.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
{dateFormat || data.date_format} (e.g.{" "}
|
||||
{formatDate(new Date(), dateFormat || "YYYY-MM-DD")})
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data.doc.status === "Not Started" ? <>
|
||||
|
||||
<ConflictingTransactions transactions={data.conflicting_transactions} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<H3 className='text-base border-0 p-0'>{_("Preview Transactions")}</H3>
|
||||
{data.final_transactions?.length === 1 ? (
|
||||
<Paragraph className='text-p-sm'>{_("We've found 1 transaction in the statement file that will be imported into the system. Please review the details below and click the 'Import' button to proceed.")}</Paragraph>
|
||||
) : (
|
||||
<Paragraph className='text-p-sm'>{_("{0} transactions will be imported into the system. Please review the details below and click the 'Import' button to proceed.", [data.final_transactions?.length?.toString() || "0"])}</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className='max-h-[400px] overflow-scroll pb-2'>
|
||||
<Table>
|
||||
<TableCaption>{_("Transactions to be imported into the system")}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-8'>#</TableHead>
|
||||
<TableHead>{_("Date")}</TableHead>
|
||||
<TableHead>{_("Description")}</TableHead>
|
||||
<TableHead>{_("Ref.")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Withdrawal")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Deposit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.final_transactions?.map((transaction, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='w-8'>{index + 1}</TableCell>
|
||||
<TableCell>{formatDate(transaction.date)}</TableCell>
|
||||
<TableCell className='max-w-[200px] w-fit overflow-hidden text-ellipsis'>{transaction.description}</TableCell>
|
||||
<TableCell className='max-w-[100px] w-fit overflow-hidden text-ellipsis'>{transaction.reference}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.withdrawal, data.currency)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.deposit, data.currency)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</> : null}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
const ConflictingTransactions = ({ transactions }: { transactions: GetStatementDetailsResponse["conflicting_transactions"] }) => {
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>
|
||||
<Alert theme="red">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>{_("Conflicting Transactions")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?")
|
||||
: _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])}
|
||||
|
||||
<div className='py-2'>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
type='button'
|
||||
theme='red'
|
||||
variant='solid'>
|
||||
<span>{transactions.length > 1 ? _("View transactions") : _("View transaction")}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Conflicting Transactions")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?")
|
||||
: _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[400px] overflow-scroll pb-2'>
|
||||
<Table>
|
||||
<TableCaption>{_("Existing transactions in the system belonging to the same bank account and date range")}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Date")}</TableHead>
|
||||
<TableHead>{_("Description")}</TableHead>
|
||||
<TableHead>{_("Ref.")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Withdrawal")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Deposit")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.name}>
|
||||
<TableCell>{formatDate(transaction.date)}</TableCell>
|
||||
<TableCell title={transaction.description} className='max-w-[200px] w-fit overflow-hidden text-ellipsis'>{transaction.description}</TableCell>
|
||||
<TableCell title={transaction.reference_number} className='max-w-[100px] w-fit overflow-hidden text-ellipsis'>{transaction.reference_number ? transaction.reference_number : "-"}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.withdrawal, transaction.currency)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.deposit, transaction.currency)}</TableCell>
|
||||
<TableCell className='text-end'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant='link' isIconButton asChild className='text-ink-gray-5 hover:text-black p-0 h-4'>
|
||||
<a href={`/desk/bank-transaction/${transaction.name}`} target='_blank' rel='noopener noreferrer'>
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Open {0} in a new tab", [transaction.name])}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' type='button'>{_("Close")}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
</Dialog>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
}
|
||||
|
||||
export default StatementDetails
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
|
||||
|
||||
export interface GetStatementDetailsResponse {
|
||||
doc: BankStatementImportLog,
|
||||
conflicting_transactions: Array<{
|
||||
name: string,
|
||||
date: string,
|
||||
withdrawal: number,
|
||||
deposit: number,
|
||||
description: string,
|
||||
reference_number: string,
|
||||
currency: string,
|
||||
}>,
|
||||
final_transactions: Array<{
|
||||
date: string,
|
||||
withdrawal: number,
|
||||
deposit: number,
|
||||
description: string,
|
||||
reference: string,
|
||||
transaction_type?: string,
|
||||
debit_credit?: string,
|
||||
included_fee?: number,
|
||||
excluded_fee?: number,
|
||||
party_name?: string,
|
||||
party_account_number?: string,
|
||||
party_iban?: string,
|
||||
}>,
|
||||
date_format: string,
|
||||
raw_data: Array<Array<string>>,
|
||||
currency: string,
|
||||
}
|
||||
|
||||
export const useGetStatementDetails = (id: string) => {
|
||||
return useFrappeGetCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.get_statement_details", {
|
||||
statement_import_id: id,
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
}
|
||||
115
banking/src/components/features/Settings/KeyboardShortcuts.tsx
Normal file
115
banking/src/components/features/Settings/KeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys'
|
||||
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import _ from '@/lib/translate'
|
||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||
|
||||
const Shortcuts = [
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>B</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <LandmarkIcon />,
|
||||
label: _("Bank Entry"),
|
||||
description: _("Record a bank journal entry for expenses, income or split transactions")
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>P</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <ReceiptIcon />,
|
||||
label: _("Record Payment"),
|
||||
description: _("Record a payment against a customer or supplier")
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>I</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <ArrowRightLeftIcon />,
|
||||
label: _("Transfer"),
|
||||
description: _("Record a transfer between two bank accounts")
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <ZapIcon />,
|
||||
label: _("Accept Matching Rule"),
|
||||
description: _("Accept the rule for the selected transaction")
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>S</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <SaveIcon />,
|
||||
label: _("Save"),
|
||||
description: _("Save the currently opened form")
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>Z</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <HistoryIcon />,
|
||||
label: _("Reconciliation History"),
|
||||
description: _("View all reconciliation actions taken in this session")
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>⇧</Kbd><Kbd>G</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <SettingsIcon />,
|
||||
label: _("Settings"),
|
||||
description: _("Open the settings dialog")
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const KeyboardShortcuts = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingsPanelHeader>
|
||||
<SettingsPanelTitle>{_("Keyboard Shortcuts")}</SettingsPanelTitle>
|
||||
<SettingsPanelDescription>{_("Get around the system quickly with keyboard shortcuts")}</SettingsPanelDescription>
|
||||
</SettingsPanelHeader>
|
||||
<SettingsPanelContent>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<p className='text-p-sm text-ink-gray-6'>
|
||||
{_("Transaction actions work when one or more unreconciled transactions are selected.")}
|
||||
<br />
|
||||
{_("To select more than one transaction at a time, press and hold the shift key.")}
|
||||
</p>
|
||||
<Table containerClassName='dark:border-outline-gray-2'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Shortcut")}</TableHead>
|
||||
<TableHead>{_("Action")}</TableHead>
|
||||
<TableHead>{_("Description")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Shortcuts.map((shortcut) => (
|
||||
<TableRow className='hover:bg-surface-gray-2'>
|
||||
<TableCell>
|
||||
{shortcut.shortcut}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge size='lg' variant='outline'>
|
||||
{shortcut.action.icon}
|
||||
{shortcut.action.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className='text-p-sm text-ink-gray-6 text-wrap'>{shortcut.action.description}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SettingsPanelContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyboardShortcuts
|
||||
46
banking/src/components/features/Settings/MatchingRules.tsx
Normal file
46
banking/src/components/features/Settings/MatchingRules.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SettingsPanelTitle, SettingsPanelHeader, SettingsPanelDescription, SettingsPanelContent } from '@/components/ui/settings-dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import RuleList, { RunRulesButton } from './Rules/RuleList'
|
||||
import CreateNewRule from '../BankReconciliation/Rules/CreateNewRule'
|
||||
import EditRule from '../BankReconciliation/Rules/EditRule'
|
||||
|
||||
const MatchingRules = () => {
|
||||
|
||||
const [selectedRule, setSelectedRule] = useState<string | null>(null)
|
||||
const [isNewRule, setIsNewRule] = useState(false)
|
||||
|
||||
|
||||
if (isNewRule) {
|
||||
return <CreateNewRule onCreate={() => setIsNewRule(false)} />
|
||||
}
|
||||
|
||||
if (selectedRule) {
|
||||
return <EditRule onClose={() => setSelectedRule(null)} ruleID={selectedRule} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanelHeader
|
||||
actions={
|
||||
<div className='flex gap-2 items-center'>
|
||||
<RunRulesButton />
|
||||
<Button type='button' onClick={() => setIsNewRule(true)}><PlusIcon /> {_("Add Rule")}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsPanelTitle>{_("Transaction Matching Rules")}</SettingsPanelTitle>
|
||||
|
||||
<SettingsPanelDescription>
|
||||
{_("Set up rules to automatically classify transactions. Drag and drop rules to reorder their priority.")}
|
||||
</SettingsPanelDescription>
|
||||
</SettingsPanelHeader>
|
||||
<SettingsPanelContent>
|
||||
<RuleList setSelectedRule={setSelectedRule} />
|
||||
</SettingsPanelContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default MatchingRules
|
||||
261
banking/src/components/features/Settings/Preferences.tsx
Normal file
261
banking/src/components/features/Settings/Preferences.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle, SettingsPanelContent } from "@/components/ui/settings-dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useTheme } from "@/components/ui/theme-provider"
|
||||
import _ from "@/lib/translate"
|
||||
import { AccountsSettings } from "@/types/Accounts/AccountsSettings"
|
||||
import { useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
||||
export const Preferences = () => {
|
||||
|
||||
|
||||
const { data: accountsSettings, mutate, error: fetchError, isLoading } = useFrappeGetDoc<AccountsSettings>("Accounts Settings", "Accounts Settings", undefined, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
|
||||
|
||||
const onUpdate = (field: keyof AccountsSettings, value: any) => {
|
||||
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
|
||||
[field]: value
|
||||
}), {
|
||||
optimisticData: {
|
||||
...accountsSettings as AccountsSettings,
|
||||
[field]: value
|
||||
},
|
||||
revalidate: false,
|
||||
}).then(() => {
|
||||
toast.success(_("Preferences updated"), {
|
||||
dismissible: true,
|
||||
duration: 500,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return <>
|
||||
|
||||
<SettingsPanelHeader>
|
||||
<SettingsPanelTitle>{_("Preferences")}</SettingsPanelTitle>
|
||||
<SettingsPanelDescription>{_("Configure settings for the banking module")}</SettingsPanelDescription>
|
||||
</SettingsPanelHeader>
|
||||
<SettingsPanelContent>
|
||||
|
||||
<div className='flex flex-col gap-4 w-full'>
|
||||
{fetchError && <ErrorBanner error={fetchError} />}
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
|
||||
<ThemeSwitcher />
|
||||
|
||||
<div className="flex justify-between items-center gap-8 py-3">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="transfer_match_days" className="text-p-base text-ink-gray-6">{_("Number of days to match transfers")}</Label>
|
||||
<p className="text-p-sm text-ink-gray-5">
|
||||
{_("For example, if set to 4, the system will try to find matching transfer transactions in other banks 4 days before and after the transaction date. This is because transactions can clear on different days on different bank accounts.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-40 flex justify-end">
|
||||
<Select disabled={isLoading} onValueChange={(value) => onUpdate("transfer_match_days", Number(value))} value={accountsSettings?.transfer_match_days?.toString()}>
|
||||
<SelectTrigger id="transfer_match_days" className="min-w-32">
|
||||
<SelectValue placeholder={_("Select number of days")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{_("Same day")}</SelectItem>
|
||||
<SelectItem value="1">{_("Within 1 day")}</SelectItem>
|
||||
<SelectItem value="2">{_("Within 2 days")}</SelectItem>
|
||||
<SelectItem value="3">{_("Within 3 days")}</SelectItem>
|
||||
<SelectItem value="4">{_("Within 4 days")}</SelectItem>
|
||||
<SelectItem value="5">{_("Within 5 days")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center gap-8 py-3">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="automatically_run_rules_on_unreconciled_transactions" className="text-p-base text-ink-gray-6">{_("Automatically run rules on unreconciled transactions")}</Label>
|
||||
<p className="text-p-sm text-ink-gray-5">
|
||||
{_("This will automatically run transaction matching rules on unreconciled transactions every hour.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Switch
|
||||
id="automatically_run_rules_on_unreconciled_transactions"
|
||||
className="dark:disabled:bg-surface-gray-2"
|
||||
disabled={isLoading}
|
||||
checked={accountsSettings?.automatically_run_rules_on_unreconciled_transactions === 1}
|
||||
onCheckedChange={(checked) => onUpdate("automatically_run_rules_on_unreconciled_transactions", checked ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center gap-8 py-3">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="enable_party_matching" className="text-p-base text-ink-gray-6">{_("Enable automatic party matching")}</Label>
|
||||
<p className="text-p-sm text-ink-gray-5">
|
||||
{_("The system will attempt to automatically match a party to a bank transaction based on account number or IBAN.")}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Switch
|
||||
id="enable_party_matching"
|
||||
className="dark:disabled:bg-surface-gray-2"
|
||||
disabled={isLoading}
|
||||
checked={accountsSettings?.enable_party_matching === 1}
|
||||
onCheckedChange={(checked) => onUpdate("enable_party_matching", checked ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center gap-8 py-3">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="enable_fuzzy_matching" className="text-p-base text-ink-gray-6">{_("Enable party name/description fuzzy matching")}</Label>
|
||||
<p className="text-p-sm text-ink-gray-5">
|
||||
{_("If a party cannot be matched by account number or IBAN, the system will try fuzzy matching using the party name and transaction description.")}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Switch
|
||||
id="enable_fuzzy_matching"
|
||||
className="dark:disabled:bg-surface-gray-2"
|
||||
disabled={accountsSettings?.enable_party_matching !== 1 || isLoading}
|
||||
checked={accountsSettings?.enable_fuzzy_matching === 1}
|
||||
onCheckedChange={(checked) => onUpdate("enable_fuzzy_matching", checked ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* <DataField
|
||||
name='transfer_match_days'
|
||||
label={_("Number of days to match transfers")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
inputMode: 'numeric',
|
||||
}}
|
||||
formDescription={_("For example, if set to 4, the system will try to find matching transactions in other banks 4 days before and after the transaction date. This is because transactions can clear on different days on different bank accounts.")}
|
||||
/> */}
|
||||
|
||||
</div>
|
||||
</SettingsPanelContent>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
const ThemeSwitcher = () => {
|
||||
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const themeCards: Array<{ value: "Light" | "Dark" | "Automatic", label: string }> = [
|
||||
{
|
||||
value: "Light",
|
||||
label: _("Light"),
|
||||
},
|
||||
{
|
||||
value: "Dark",
|
||||
label: _("Dark"),
|
||||
},
|
||||
{
|
||||
value: "Automatic",
|
||||
label: _("System"),
|
||||
},
|
||||
]
|
||||
|
||||
return <div className="flex flex-col gap-3 pb-3">
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-p-base text-ink-gray-6">{_("Theme")}</Label>
|
||||
<p className="text-p-sm text-ink-gray-5">
|
||||
{_("Switch between light, dark, or system theme")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{themeCards.map((option) => {
|
||||
const selected = theme === option.value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(option.value)}
|
||||
aria-pressed={selected}
|
||||
className={`flex-1 basis-0 min-w-0 overflow-hidden rounded-lg border cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-blue-4 ${selected ? "border-outline-gray-5" : "border-outline-gray-modals hover:border-outline-gray-4"}`}
|
||||
>
|
||||
{option.value === "Automatic" ? (
|
||||
<div className="flex w-full min-w-0">
|
||||
<ThemePreviewWindow theme="light" roundedClass="rounded-tl-[10.5px]" />
|
||||
<ThemePreviewWindow theme="dark" roundedClass="rounded-tr-[10.5px]" />
|
||||
</div>
|
||||
) : (
|
||||
<ThemePreviewWindow theme={option.value === "Light" ? "light" : "dark"} roundedClass="rounded-t-[10.5px]" />
|
||||
)}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-outline-gray-modals">
|
||||
<div className="text-base text-ink-gray-7">{option.label}</div>
|
||||
<span className={`rounded-full size-3.5 ${selected ? "border-4 border-outline-gray-5" : "border border-outline-gray-4"}`} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const ThemePreviewWindow = ({ theme, roundedClass }: { theme: "light" | "dark", roundedClass: string }) => {
|
||||
const isLight = theme === "light"
|
||||
const frameClass = isLight ? "bg-white border-gray-100" : "bg-gray-900 border-gray-800"
|
||||
const subtleSurfaceClass = isLight ? "bg-gray-50" : "bg-gray-800"
|
||||
const mutedLineClass = isLight ? "bg-gray-200" : "bg-gray-700"
|
||||
const mutedLineStrongClass = isLight ? "bg-gray-300" : "bg-gray-600"
|
||||
const dividerClass = isLight ? "border-gray-100" : "border-gray-800"
|
||||
const cardClass = isLight ? "bg-white border-gray-200" : "bg-gray-900 border-gray-700"
|
||||
|
||||
return <div className={`flex flex-1 min-w-0 pl-5 pt-3.5 ${isLight ? "bg-surface-gray-2" : "bg-surface-gray-3"} ${roundedClass}`}>
|
||||
<div className={`w-full rounded-tl-sm border ${frameClass}`}>
|
||||
<div className={`flex gap-[3px] py-[3px] px-1 border-b ${dividerClass}`}>
|
||||
<div className="size-1.5 bg-[#FF5F57] rounded-full" />
|
||||
<div className="size-1.5 bg-[#FEBC2D] rounded-full" />
|
||||
<div className="size-1.5 bg-[#28C840] rounded-full" />
|
||||
</div>
|
||||
<div className="p-1.5">
|
||||
<div className={`flex items-center gap-1.5 p-1 rounded-sm border ${subtleSurfaceClass} ${dividerClass}`}>
|
||||
<div className={`h-2 w-8 rounded-full ${mutedLineStrongClass}`} />
|
||||
<div className={`h-2 w-6 rounded-full ${mutedLineClass}`} />
|
||||
<div className={`h-2 w-7 rounded-full ml-auto ${mutedLineClass}`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 mt-1.5">
|
||||
<div className={`rounded-sm border p-1 ${cardClass}`}>
|
||||
<div className={`h-1.5 w-full rounded-full ${mutedLineStrongClass}`} />
|
||||
<div className={`h-1.5 w-4/5 rounded-full mt-1 ${mutedLineClass}`} />
|
||||
<div className={`h-1.5 w-3/5 rounded-full mt-1 ${mutedLineClass}`} />
|
||||
</div>
|
||||
<div className={`rounded-sm border p-1 ${cardClass}`}>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className={`h-1.5 w-2/5 rounded-full ${mutedLineStrongClass}`} />
|
||||
{/* <div className={`h-2.5 w-5 rounded-sm border ${chipClass}`} /> */}
|
||||
</div>
|
||||
<div className={`h-1.5 w-full rounded-full mt-1 ${mutedLineClass}`} />
|
||||
<div className={`h-1.5 w-3/4 rounded-full mt-1 ${mutedLineClass}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
314
banking/src/components/features/Settings/Rules/RuleList.tsx
Normal file
314
banking/src/components/features/Settings/Rules/RuleList.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import _ from "@/lib/translate"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappeGetDocList, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { ArrowDownRight, ArrowDownUp, ArrowUpRight, MoreVertical, Trash2, GripVertical, Play, RefreshCw, ZapIcon, CalendarSyncIcon } from "lucide-react"
|
||||
import { useContext, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const useGetRuleList = () => {
|
||||
return useFrappeGetDocList<BankTransactionRule>("Bank Transaction Rule", {
|
||||
fields: ["name", "rule_name", "rule_description", "transaction_type", "priority"],
|
||||
orderBy: {
|
||||
field: 'priority',
|
||||
order: 'asc'
|
||||
},
|
||||
limit: 100
|
||||
})
|
||||
}
|
||||
|
||||
export const RunRulesButton = () => {
|
||||
|
||||
const { data } = useGetRuleList()
|
||||
|
||||
const { call: runRuleEvaluation, loading: isRunningRules } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction_rule.bank_transaction_rule.run_rule_evaluation')
|
||||
|
||||
const handleRunRules = async (forceEvaluate: boolean = false) => {
|
||||
try {
|
||||
await runRuleEvaluation({
|
||||
force_evaluate: forceEvaluate
|
||||
})
|
||||
toast.success(forceEvaluate ? _("Rules evaluation started") : _("Rules evaluation completed"))
|
||||
} catch (error) {
|
||||
toast.error(_("Failed to run rules evaluation"))
|
||||
console.error("Error running rules evaluation:", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isRunningRules}>
|
||||
{isRunningRules ? (
|
||||
<RefreshCw className="animate-spin" />
|
||||
) : (
|
||||
<Play />
|
||||
)}
|
||||
{isRunningRules ? _("Running...") : _("Run Rules")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => handleRunRules(false)} disabled={isRunningRules} title={_("Run rules on unreconciled transactions that haven't been evaluated yet")}>
|
||||
<Play />
|
||||
{_("Run on new transactions")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRunRules(true)} disabled={isRunningRules} title={_("Force re-evaluate all unreconciled transactions, even if they were previously evaluated")}>
|
||||
<RefreshCw />
|
||||
{_("Force evaluate all")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<AutoRunRuleItem />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
|
||||
const AutoRunRuleItem = () => {
|
||||
|
||||
const { db } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const { data: accountsSetting, mutate: setAutomaticallyRunRulesOnUnreconciledTransactions } = useFrappeGetCall("frappe.client.get_single_value", {
|
||||
"doctype": "Accounts Settings",
|
||||
"field": "automatically_run_rules_on_unreconciled_transactions"
|
||||
})
|
||||
|
||||
const automaticallyRunRulesOnUnreconciledTransactions = accountsSetting?.message ? true : false
|
||||
|
||||
const onAutoClassifyTransactions = (checked: boolean) => {
|
||||
toast.promise(db.setValue("Accounts Settings", "Accounts Settings", "automatically_run_rules_on_unreconciled_transactions", checked ? 1 : 0).then(() => {
|
||||
setAutomaticallyRunRulesOnUnreconciledTransactions({
|
||||
message: {
|
||||
automatically_run_rules_on_unreconciled_transactions: checked ? 1 : 0,
|
||||
}
|
||||
}, {
|
||||
revalidate: false
|
||||
})
|
||||
}), {
|
||||
loading: _("Updating..."),
|
||||
success: checked ? _("Scheduled job enabled. Transactions will be auto classified.") : _("Scheduled job disabled. Transactions will not be auto classified."),
|
||||
error: _("Failed to update auto classify transactions settings")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return <DropdownMenuCheckboxItem
|
||||
checked={automaticallyRunRulesOnUnreconciledTransactions}
|
||||
onCheckedChange={onAutoClassifyTransactions}>
|
||||
<CalendarSyncIcon />
|
||||
{_("Run rules automatically")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
}
|
||||
|
||||
|
||||
|
||||
const RuleList = ({ setSelectedRule }: { setSelectedRule: (rule: string) => void }) => {
|
||||
|
||||
const { data, error, isLoading, mutate } = useGetRuleList()
|
||||
|
||||
const { db } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const onDeleteRule = (ruleID: string) => {
|
||||
toast.promise(db.deleteDoc("Bank Transaction Rule", ruleID).then(() => {
|
||||
mutate()
|
||||
}), {
|
||||
loading: _("Deleting rule..."),
|
||||
success: _("Rule deleted."),
|
||||
error: _("Failed to delete rule.")
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id && data) {
|
||||
const oldIndex = data.findIndex((rule) => rule.name === active.id)
|
||||
const newIndex = data.findIndex((rule) => rule.name === over?.id)
|
||||
|
||||
const newData = arrayMove(data, oldIndex, newIndex)
|
||||
|
||||
// Update priorities based on new order
|
||||
const updatePromises = newData.map((rule, index) => {
|
||||
const newPriority = index + 1
|
||||
if (rule.priority !== newPriority) {
|
||||
return db.setValue("Bank Transaction Rule", rule.name, "priority", newPriority)
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(updatePromises)
|
||||
toast.success(_("Rule priorities updated"))
|
||||
mutate() // Refresh the data
|
||||
} catch (error) {
|
||||
toast.error(_("Failed to update rule priorities"))
|
||||
console.error("Error updating priorities:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-y-auto">
|
||||
{isLoading && <div className="flex flex-col gap-2">
|
||||
<Skeleton className="w-full h-10" />
|
||||
<Skeleton className="w-full h-10" />
|
||||
<Skeleton className="w-full h-10" />
|
||||
<Skeleton className="w-full h-10" />
|
||||
<Skeleton className="w-full h-10" />
|
||||
</div>}
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.length === 0 && <Empty className="h-96">
|
||||
<EmptyMedia>
|
||||
<ZapIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No rules setup yet")}</EmptyTitle>
|
||||
<EmptyDescription>{_("Configure rules to save time when reconciling transactions.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
|
||||
</Empty>}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={data.map(rule => rule.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<ul className="space-2 divide-y divide-outline-gray-modals">
|
||||
{data?.map((rule) => (
|
||||
<SortableRuleItem
|
||||
key={rule.name}
|
||||
rule={rule}
|
||||
setSelectedRule={setSelectedRule}
|
||||
onDeleteRule={onDeleteRule}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const SortableRuleItem = ({
|
||||
rule,
|
||||
setSelectedRule,
|
||||
onDeleteRule
|
||||
}: {
|
||||
rule: BankTransactionRule
|
||||
setSelectedRule: (rule: string) => void
|
||||
onDeleteRule: (ruleID: string) => void
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: rule.name })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<li ref={setNodeRef} style={style}>
|
||||
<div className={cn("flex justify-between items-center py-2 my-0.5 h-full hover:bg-surface-gray-1 pe-2 rounded", isDropdownOpen && "bg-surface-gray-1")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 rounded"
|
||||
title={_("Drag to reorder")}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-ink-gray-5" />
|
||||
</div>
|
||||
<Badge theme="gray" className="font-numeric tabular-nums">
|
||||
{rule.priority}
|
||||
</Badge>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className="p-0 h-fit text-start cursor-pointer no-underline hover:underline"
|
||||
onClick={() => setSelectedRule(rule.name)}>
|
||||
{rule.rule_name}
|
||||
</Button>
|
||||
<div title={rule.transaction_type === "Any" ? _("Applies to withdrawals and deposits") : rule.transaction_type === "Withdrawal" ? _("Applies to withdrawals") : _("Applies to deposits")}>
|
||||
{rule.transaction_type === "Any" ? <ArrowDownUp className="text-ink-gray-5 w-4 h-4" /> : rule.transaction_type === "Withdrawal" ? <ArrowUpRight className="text-ink-red-3 w-5 h-5" /> : <ArrowDownRight className="text-ink-green-3 w-5 h-5" />}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-ink-gray-5">
|
||||
{rule.rule_description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 h-full justify-center">
|
||||
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' isIconButton className="hover:bg-transparent">
|
||||
<MoreVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDeleteRule(rule.name)}>
|
||||
<Trash2 />
|
||||
{_("Delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default RuleList
|
||||
95
banking/src/components/features/Settings/Settings.tsx
Normal file
95
banking/src/components/features/Settings/Settings.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
SettingsDialog,
|
||||
SettingsPanel,
|
||||
SettingsPanels,
|
||||
SettingsTabGroup,
|
||||
SettingsTabItem,
|
||||
SettingsTabs,
|
||||
} from '@/components/ui/settings-dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Preferences } from './Preferences'
|
||||
import MatchingRules from './MatchingRules'
|
||||
import KeyboardShortcuts from './KeyboardShortcuts'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
const Settings = () => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useHotkeys('shift+meta+g', () => {
|
||||
setIsOpen(x => !x)
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: false
|
||||
})
|
||||
|
||||
return (
|
||||
<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 >
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
Reference in New Issue
Block a user