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 { useVirtualizer } from '@tanstack/react-virtual' 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 { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import TransferModal from "./TransferModal" import BankEntryModal from "./BankEntryModal" import RecordPaymentModal from "./RecordPaymentModal" import SelectedTransactionsTable from "./SelectedTransactionsTable" import MatchFilters from "./MatchFilters" import { useHotkeys } from "react-hotkeys-hook" import { KeyboardMetaKeyIcon } from "@/components/ui/keyboard-keys" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { useFrappeGetCall } from "frappe-react-sdk" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Link } from "react-router" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group" const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => { const selectedBank = useAtomValue(selectedBankAccountAtom) if (!selectedBank) { return {_("Select a bank account to reconcile")} } return <>

{_("Unreconciled Transactions")}

{_("Match or Create")}

} /** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */ function VirtualizedListBody({ items, height, getItemKey, children, estimateSize = 74, }: { items: T[] height: number getItemKey: (item: T, index: number) => string | number children: (item: T, index: number) => React.ReactNode estimateSize?: number }) { const scrollRef = useRef(null) const rowVirtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollRef.current, estimateSize: () => estimateSize, overscan: 8, getItemKey: (index) => String(getItemKey(items[index], index)), }) if (items.length === 0) { return null } return (
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
{children(items[virtualRow.index], virtualRow.index)}
))}
) } const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => { const bankAccount = useAtomValue(selectedBankAccountAtom) const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '') const currencySymbol = getCurrencySymbol(currency) const formatInfo = getCurrencyFormatInfo(currency) const groupSeparator = formatInfo.group_sep || "," const decimalSeparator = formatInfo.decimal_str || "." const inputRef = useRef(null) const { data: unreconciledTransactions, isLoading, error } = useGetUnreconciledTransactions() const [typeFilter, setTypeFilter] = useAtom(bankRecTransactionTypeFilter) const [amountFilter, setAmountFilter] = useAtom(bankRecAmountFilter) const [search, setSearch] = useTransactionSearch() const searchIndex = useMemo(() => { if (!unreconciledTransactions) { return null } return new Fuse(unreconciledTransactions.message, { keys: ['description', 'reference_number'], threshold: 0.5, includeScore: true }) }, [unreconciledTransactions]) const results = useMemo(() => { return getSearchResults(searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message) }, [searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message]) const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(bankAccount?.name || '')) const onFilterChange = () => { setSelectedTransaction([]) } const onSearchChange = (e: React.ChangeEvent) => { setSearch(e.target.value) onFilterChange() } const onTypeFilterChange = (type: string) => { setTypeFilter(type) onFilterChange() } const onClearFilters = () => { setSearch('') if (inputRef.current) { inputRef.current.value = '' } setTypeFilter('All') setAmountFilter({ value: 0, stringValue: '' }) onFilterChange() } const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0 const listHeight = contentHeight - 72 if (isLoading) { return } return
{results?.length} {_(results?.length === 1 ? "result" : "results")}
{ // 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} />
onTypeFilterChange('All')}> {_("All")} onTypeFilterChange('Debits')}> {_("Debits")} onTypeFilterChange('Credits')}> {_("Credits")}
{error && } {results.length === 0 && } transaction.name} > {(transaction) => }
} const NoTransactionsFoundBanner = ({ text, description, onClearFilters }: { text: string, description?: string, onClearFilters?: () => void }) => { return {text} {description && {description}} {onClearFilters ? : } } const UnreconciledTransactionsLoadingState = () => { return
{Array.from({ length: 6 }).map((_, index) => ( ))}
} const UnreconciledTransactionItem = ({ transaction }: { transaction: UnreconciledTransaction }) => { const selectedBank = useAtomValue(selectedBankAccountAtom) const [selectedTransaction, setSelectedTransaction] = useAtom(bankRecSelectedTransactionAtom(selectedBank?.name || '')) const { amount, isWithdrawal } = useIsTransactionWithdrawal(transaction) const isSelected = selectedTransaction?.some((t) => t.name === transaction.name) const currency = transaction.currency ?? selectedBank?.account_currency ?? getCompanyCurrency(selectedBank?.company ?? '') const handleSelectTransaction = (event: React.MouseEvent) => { // If the user is pressing the shift key, add/remove the transaction from the selected transactions if (event.shiftKey) { setSelectedTransaction(isSelected ? selectedTransaction.filter((t) => t.name !== transaction.name) : [...selectedTransaction, transaction]) } else { setSelectedTransaction([transaction]) } } return
{formatDate(transaction.date)} {transaction.transaction_type && {transaction.transaction_type}} {transaction.reference_number && {_("Ref")}: {transaction.reference_number}} {transaction.matched_transaction_rule && {transaction.matched_transaction_rule}}
{transaction.description}
{isWithdrawal ? : } {amount && amount > 0 && {formatCurrency(amount, currency)}} {amount !== transaction.unallocated_amount && {formatCurrency(transaction.unallocated_amount, currency)} {_("Unallocated")}}
} const VouchersSection = ({ contentHeight }: { contentHeight: number }) => { const selectedBank = useAtomValue(selectedBankAccountAtom) const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || '')) if (selectedTransactions.length === 0) { return {_("Select a transaction to match and reconcile with vouchers")} } if (selectedTransactions.length > 1) { return } return
} const useKeyboardShortcuts = () => { const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom) const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom) const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) useHotkeys('meta+p', () => { // setRecordPaymentModalOpen(true) }, { enabled: true, enableOnFormTags: false, preventDefault: true }) useHotkeys('meta+b', () => { // setRecordJournalEntryModalOpen(true) }, { enabled: true, enableOnFormTags: false, preventDefault: true }) useHotkeys('meta+i', () => { // setTransferModalOpen(true) }, { enabled: true, enableOnFormTags: false, preventDefault: true }) return { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } } const OptionsForMultipleTransactions = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts() return
{transactions.length} {_(transactions.length === 1 ? _("transaction selected") : _("transactions selected"))} {formatCurrency(transactions.reduce((acc, transaction) => acc + (transaction.unallocated_amount ?? 0), 0), transactions[0].currency ?? '')}
{_("Record a journal entry for expenses, income or split transactions")} B {_("Record a payment entry against a customer or supplier")} P {_("Record an internal transfer to another bank/credit card/cash account")} I
} const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => { const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts() return
{_("Record a payment entry against a customer or supplier")} P {_("Record a journal entry for expenses, income or split transactions")} B {_("Record an internal transfer to another bank/credit card/cash account")} I
{transaction.matched_transaction_rule && }
} const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) => { const { data: rule } = useGetRuleForTransaction(transaction) const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom) const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom) const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) const getActionIcon = () => { if (!rule) return null switch (rule.classify_as) { case "Bank Entry": return case "Payment Entry": return case "Transfer": return default: return } } const getActionStyles = () => { if (!rule) return {} 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 = () => { if (!rule) return switch (rule.classify_as) { case "Bank Entry": setRecordJournalEntryModalOpen(true) break case "Payment Entry": setRecordPaymentModalOpen(true) break case "Transfer": setTransferModalOpen(true) break } } const getActionDescription = () => { if (!rule) return "" 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('alt+r', () => { handleActionClick() }, { enabled: true, enableOnFormTags: false, preventDefault: true }) const styles = getActionStyles() if (!rule) { return null } return (
{getActionIcon()}
{rule.rule_name} {rule.rule_description || _("Rule matched based on transaction description and other criteria.")}
{rule.classify_as}
{_("Recommended Action")}
{_("Priority")} {rule.priority}
{rule.account && (
{_("Account")}: {rule.account}
)} {rule.party_type && rule.party && (
{_("Party")}: {rule.party} ({_(rule.party_type)})
)}

{getActionDescription()}

) } const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => { const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction) const voucherList = vouchers?.message ?? [] const listHeight = contentHeight - 120 if (error) { return } if (isLoading) { return
or
} return
or
{voucherList.length === 0 && {_("No vouchers found for this transaction")} } voucher.name} > {(voucher, index) => }
} const VoucherItem = ({ voucher, index }: { voucher: LinkedPayment, index: number }) => { const selectedBank = useAtomValue(selectedBankAccountAtom) const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || '')) const { amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested } = useMemo(() => { const transaction = selectedTransaction?.[0] // We need to check if the following details match: // Amount // Date // Reference/Description: Full or partial // Whether this is suggested or not - depends on the above scores const amountMatches = voucher.paid_amount === transaction?.unallocated_amount const postingDateMatches = voucher.posting_date === transaction?.date const referenceDateMatches = voucher.reference_date === transaction?.date const referenceMatchesFull = voucher.reference_no === transaction?.reference_number || voucher.reference_no === transaction?.description const referenceMatchesPartial = transaction?.reference_number?.includes(voucher.reference_no) || transaction?.description?.includes(voucher.reference_no) const isSuggested = amountMatches && (postingDateMatches || referenceDateMatches || referenceMatchesPartial) && index === 0 return { isSelected: false, amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested: isSuggested } }, [voucher, selectedTransaction, index]) const { reconcileTransaction, loading } = useReconcileTransaction() const onClick = () => { if (!selectedTransaction) { return } reconcileTransaction(selectedTransaction[0], voucher) } return
{_(voucher.doctype)} {voucher.name}
{voucher.party && voucher.party_type &&
{_(voucher.party_type)} {voucher.party}
}
{_("Amount")}
{formatCurrency(voucher.paid_amount, voucher.currency)} {amountMatches ? : }
{_("Posted On")}
{formatDate(voucher.posting_date)} {postingDateMatches ? : }
{voucher.reference_date &&
{_("Reference Date")}
{formatDate(voucher.reference_date)} {referenceDateMatches ? : }
}
{voucher.reference_no &&
{voucher.reference_no}    {referenceMatchesFull ? `${_("Complete Match")}` : referenceMatchesPartial ? `${_("Partial Match")}` : `${_("No Match")}`} {referenceMatchesFull ? `${_("Reference matches the selected transaction")}` : referenceMatchesPartial ? `${_("Reference matches the selected transaction partially")}` : `${_("Reference does not match the selected transaction")}`}
}
{isSuggested &&
{_("Suggested")}
}
} const MatchBadge = ({ matchType, label }: { matchType: 'full' | 'partial' | 'none', label: string }) => { return {matchType === 'full' ? : matchType === 'partial' ? {_("Partial Match")} : } {label} } const OlderUnreconciledTransactionsBanner = () => { // A banner to show when there are unreconciled transactions for the given bank account before the current selected date const [dates, setDates] = useAtom(bankRecDateAtom) const selectedBank = useAtomValue(selectedBankAccountAtom) const { data } = useFrappeGetCall<{ message: { count: number, oldest_date: string } }>("erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_older_unreconciled_transactions", { bank_account: selectedBank?.name, from_date: dates.fromDate, }, undefined, { revalidateOnFocus: false, }) if (data && data.message.count > 0) { return
{data.message.count > 1 ? ( {_("There are {0} unreconciled transactions before {1}.", [data.message.count.toString(), formatDate(dates.fromDate)])} ) : ( {_("There is one unreconciled transaction before {0}.", [formatDate(dates.fromDate)])} )} {_("The opening balance might not match your bank statement. Would you like to reconcile them?")}
} return null } export default MatchAndReconcile