mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-18 12:39:18 +00:00
* 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
555 lines
24 KiB
TypeScript
555 lines
24 KiB
TypeScript
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 |