Files
erpnext/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx
Nikhil Kothari 6de5367f12 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
2026-05-09 23:14:58 +05:30

1301 lines
51 KiB
TypeScript

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import { bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose, DialogTrigger } 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 { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
import { FrappeConfig, FrappeContext, useFrappeGetCall, 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 { ChangeEvent, useCallback, useContext, useEffect, useMemo, useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Checkbox } from "@/components/ui/checkbox"
import { AlertCircleIcon, 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 { PaymentEntry } from "@/types/Accounts/PaymentEntry"
import { H4 } from "@/components/ui/typography"
import { usePaymentEntryCalculations } from "@/hooks/usePaymentEntryCalculations"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { formatDate, today } from "@/lib/date"
import { slug } from "@/lib/frappe"
import MarkdownRenderer from "@/components/ui/markdown"
import { Separator } from "@/components/ui/separator"
import { PaymentEntryDeduction } from "@/types/Accounts/PaymentEntryDeduction"
import { TableLoader } from "@/components/ui/loaders"
import SelectedTransactionsTable from "./SelectedTransactionsTable"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Label } from "@/components/ui/label"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import FileUploadBanner from "@/components/common/FileUploadBanner"
import { useHotkeys } from "react-hotkeys-hook"
const RecordPaymentModal = () => {
const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-[95vw]'>
<DialogHeader>
<DialogTitle>{_("Record Payment")}</DialogTitle>
<DialogDescription>
{_("Record a payment entry against a customer or supplier")}
</DialogDescription>
</DialogHeader>
<RecordPaymentModalContent />
</DialogContent>
</Dialog>
)
}
const RecordPaymentModalContent = () => {
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 <PaymentEntryForm
selectedBankAccount={selectedBankAccount}
selectedTransaction={selectedTransaction[0]} />
}
return <BulkPaymentEntryForm
transactions={selectedTransaction} />
}
const BulkPaymentEntryForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom)
const form = useForm<{
party_type: PaymentEntry['party_type'],
party: PaymentEntry['party'],
party_name: PaymentEntry['party_name'],
/** GL account that's paid from or paid to */
account: string
mode_of_payment: PaymentEntry['mode_of_payment']
}>()
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_payment_entry_and_reconcile')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const onSubmit = (data: { party_type: PaymentEntry['party_type'], party: PaymentEntry['party'], account: string, mode_of_payment: PaymentEntry['mode_of_payment'] }) => {
createPaymentEntry({
bank_transaction_names: transactions.map((transaction) => transaction.name),
party_type: data.party_type,
party: data.party,
account: data.account
}).then(({ message }) => {
addToActionLog({
type: 'payment',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: item.payment_entry.name,
reference_no: item.payment_entry.reference_no,
reference_date: item.payment_entry.reference_date,
posting_date: item.payment_entry.posting_date,
party_type: item.payment_entry.party_type,
party: item.payment_entry.party,
doc: item.payment_entry,
}
})),
bulkCommonData: {
party_type: data.party_type,
party: data.party,
account: data.account,
}
})
toast.success(_("Payment Recorded"), {
duration: 4000,
closeButton: true,
})
onReconcile(transactions[transactions.length - 1])
setIsOpen(false)
})
}
const party_type = useWatch({ control: form.control, name: 'party_type' })
const party_name = useWatch({ control: form.control, name: 'party_name' })
const party = useWatch({ control: form.control, name: 'party' })
const { call } = useContext(FrappeContext) as FrappeConfig
const currentCompany = useCurrentCompany()
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
const onPartyChange = (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) => {
form.setValue('party_name', res.message.party_name)
form.setValue('account', res.message.party_account)
})
} else {
// Clear the party and account
form.setValue('party_name', '')
form.setValue('account', '')
}
}
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-8 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">
{party_type ? <LinkFormField
name={`party`}
label={_("Party")}
isRequired
rules={{
onChange: onPartyChange,
required: _('Party is required')
}}
// Show the party name if it's different from the party - usually the case when a naming series is used
formDescription={party_name !== party ? party_name : undefined}
doctype={party_type}
/> : <DataField
name={`party`}
label={_("Party")}
rules={{
required: _('Party is required')
}}
isRequired
inputProps={{
disabled: true,
}}
/>
}
</div>
<div className="col-span-2">
<AccountFormField
name='account'
label={_("Account")}
isRequired
rules={{
required: _('Account is required')
}}
account_type={['Payable', 'Receivable']}
filterFunction={(acc) => {
if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') {
return acc.account_type === 'Payable'
} else if (party_type === 'Customer') {
return acc.account_type === 'Receivable'
}
return true
}}
/>
</div>
<div className="col-span-2">
<LinkFormField
name='mode_of_payment'
label={_("Mode of Payment")}
doctype="Mode of Payment"
/>
</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 PaymentEntryForm = ({ selectedTransaction, selectedBankAccount }: { selectedTransaction: UnreconciledTransaction, selectedBankAccount: SelectedBank }) => {
const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom)
const onClose = () => {
setIsOpen(false)
}
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const form = useForm<PaymentEntry>({
defaultValues: {
payment_type: isWithdrawal ? 'Pay' : 'Receive',
bank_account: selectedTransaction.bank_account,
company: selectedTransaction?.company,
// If the money is paid, it's usually to a supplier. If it's received, it's usually from a customer
party_type: rule?.party_type ?? (isWithdrawal ? 'Supplier' : 'Customer'),
party: rule?.party ?? '',
// 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,
base_paid_amount: selectedTransaction.unallocated_amount,
received_amount: selectedTransaction.unallocated_amount,
base_received_amount: selectedTransaction.unallocated_amount,
reference_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
target_exchange_rate: 1,
source_exchange_rate: 1,
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const setUnpaidInvoiceOpen = useSetAtom(isUnpaidInvoicesButtonOpen)
useEffect(() => {
if (rule && rule.party && rule.party_type && rule.account) {
setUnpaidInvoiceOpen(true)
}
}, [rule, setUnpaidInvoiceOpen])
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_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: PaymentEntry) => {
createPaymentEntry({
bank_transaction_name: selectedTransaction.name,
payment_entry_doc: {
...data,
custom_remarks: data.remarks ? true : false
}
}).then(async ({ message }) => {
addToActionLog({
type: 'payment',
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(_("Payment 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: "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
})
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 items-start'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-2'>
<H4 className="text-base">{isWithdrawal ? _("Paid to") : _("Received from")}</H4>
<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'
},
type: isWithdrawal ? 'Payable' : 'Receivable'
}}
rules={{
required: "Party Type is required"
}}
/>
</div>
<div className="col-span-3">
<PartyField />
</div>
<div className="col-span-2">
<AccountDropdown isWithdrawal={isWithdrawal} />
</div>
<div className="col-span-2">
<LinkFormField
name='mode_of_payment'
label={_("Mode of Payment")}
doctype="Mode of Payment"
/>
</div>
</div>
</div>
</div>
<Separator />
<InvoicesSection currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
<Separator />
<OtherChargesSection currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
<Separator />
<div className="grid grid-cols-2 gap-4">
<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
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
<SmallTextField
name='remarks'
label={_("Custom Remarks")}
formDescription={"This will be auto-populated if not set."}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button type='submit' size='md' disabled={loading || isUploading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const isUnpaidInvoicesButtonOpen = atom(false)
const PartyField = () => {
const { control, setValue } = useFormContext<PaymentEntry>()
const party_type = useWatch({
control,
name: `party_type`
})
const { call } = useContext(FrappeContext) as FrappeConfig
const company = useWatch({ control, name: 'company' })
const party_name = useWatch({ control, name: 'party_name' })
const type = useWatch({ control, name: 'payment_type' })
const party = useWatch({ control, name: 'party' })
const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen)
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('party_name', res.message.party_name)
if (type === 'Pay') {
setValue('paid_to', res.message.party_account)
} else {
setValue('paid_from', res.message.party_account)
}
setIsOpen(true)
})
} else {
// Clear the party and account
setValue('party_name', '')
if (type === 'Pay') {
setValue('paid_to', '')
} else {
setValue('paid_from', '')
}
}
}
if (!party_type) {
return <DataField
name={`party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
}}
/>
}
return <LinkFormField
name={`party`}
label={_("Party")}
rules={{
onChange
}}
// Show the party name if it's different from the party - usually the case when a naming series is used
formDescription={party_name !== party ? party_name : undefined}
doctype={party_type}
/>
}
const AccountDropdown = ({ isWithdrawal }: { isWithdrawal: boolean }) => {
// If it's a withdrawal, then we need to show the "Paid to" account
// If it's a deposit, then we need to show the "Paid from" account
const { control, setValue } = useFormContext<PaymentEntry>()
const party_type = useWatch({ control, name: 'party_type' })
const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen)
const accountTypes: string[] | undefined = useMemo(() => {
if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') {
return ['Payable']
} else if (party_type === 'Customer') {
return ['Receivable']
}
return undefined
}, [party_type])
const onAccountChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.value) {
setValue('unallocated_amount', 0)
setValue('total_allocated_amount', 0)
setValue('difference_amount', 0)
setValue('references', [])
setIsOpen(true)
}
}
if (isWithdrawal) {
return <AccountFormField
name='paid_to'
label={_("Paid To (GL Account)")}
isRequired
rules={{
required: 'Paid To is required',
onChange: onAccountChange
}}
account_type={accountTypes}
/>
} else {
return <AccountFormField
name='paid_from'
label={_("Paid From (GL Account)")}
isRequired
rules={{
required: 'Paid From is required',
onChange: onAccountChange
}}
account_type={accountTypes}
/>
}
}
const InvoicesSection = ({ currency }: { currency: string }) => {
const { setTotalAllocatedAmount } = usePaymentEntryCalculations()
const { control } = useFormContext<PaymentEntry>()
const { fields, remove } = useFieldArray({
control,
name: 'references'
})
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])
return <div className="flex flex-col gap-2">
<div className="flex gap-4 items-center">
<H4 className="text-base">{_("Invoices")}</H4>
<GetUnpaidInvoicesButton />
</div>
<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>{_("Reference Document")}</TableHead>
<TableHead>{_("Invoice No")}</TableHead>
<TableHead>{_("Due Date")}</TableHead>
<TableHead className="text-end">{_("Grand Total")}</TableHead>
<TableHead className="text-end">{_("Outstanding")}</TableHead>
<TableHead className="text-end">{_("Allocated")}</TableHead>
<TableHead className='w-14'></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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>
<a
target="_blank"
className="underline underline-offset-2"
href={`/desk/${slug(field.reference_doctype)}/${field.reference_name}`}>{field.reference_doctype}: {field.reference_name}</a>
</TableCell>
<TableCell>
{field.bill_no ?? "-"}
</TableCell>
<TableCell>
{formatDate(field.due_date)}
</TableCell>
<TableCell className="text-end">
{formatCurrency(field.total_amount, currency)}
</TableCell>
<TableCell className="text-end">
{formatCurrency(field.outstanding_amount, currency)}
</TableCell>
<TableCell className="text-end max-w-36">
<CurrencyFormField
name={`references.${index}.allocated_amount`}
label={_("Allocated")}
isRequired
rules={{
onChange: () => setTotalAllocatedAmount()
}}
hideLabel
currency={currency}
/>
</TableCell>
<TableCell>
<DifferenceButton index={index} currency={currency} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between gap-2">
<div className="flex gap-2 justify-end">
{selectedRows.length > 0 && <div>
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
</div>}
</div>
<Summary currency={currency} />
</div>
</div>
}
const DifferenceButton = ({ index, currency }: { index: number, currency: string }) => {
const { setTotalAllocatedAmount } = usePaymentEntryCalculations()
const { control, setValue } = useFormContext<PaymentEntry>()
const outstandingAmount = useWatch({
control,
name: `references.${index}.outstanding_amount`
}) ?? 0
const allocatedAmount = useWatch({
control,
name: `references.${index}.allocated_amount`
}) ?? 0
const difference = flt(outstandingAmount - allocatedAmount, 2)
const onPayInFull = useCallback(() => {
setValue(`references.${index}.allocated_amount`, outstandingAmount, { shouldDirty: true })
setTotalAllocatedAmount()
}, [outstandingAmount, index, setValue, setTotalAllocatedAmount])
if (difference !== 0) {
return <Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
onClick={onPayInFull}
isIconButton
className="text-ink-gray-5">
<AlertCircleIcon />
</Button>
</TooltipTrigger>
<TooltipContent>
{_("The invoice is not fully allocated as there is a difference of {0}.", [formatCurrency(difference, currency) ?? ''])}
<br />
{_("Click to pay in full.")}
</TooltipContent>
</Tooltip>
}
return null
}
const Summary = ({ currency }: { currency: string }) => {
const { control, setValue, getValues } = useFormContext<PaymentEntry>()
const { setUnallocatedAmount } = usePaymentEntryCalculations()
const amount = useWatch({
control,
name: 'paid_amount'
})
const unallocatedAmount = useWatch({
control,
name: 'unallocated_amount'
})
const allocatedAmount = useWatch({
control,
name: 'total_allocated_amount'
})
const differenceAmount = useWatch({
control,
name: 'difference_amount'
})
const onAddRow = useCallback((amount?: number) => {
if (amount) {
const deductions = getValues('deductions') ?? []
setValue('deductions', [...deductions, {
amount: amount,
account: '',
cost_center: getCompanyCostCenter(getValues('company')),
description: ''
} as PaymentEntryDeduction])
setUnallocatedAmount()
}
}, [setUnallocatedAmount, getValues, setValue])
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 Amount")}</TextComponent>
<TextComponent>{formatCurrency(amount, currency)}</TextComponent>
</div>
<div className="flex gap-2 justify-between">
<TextComponent>{_("Allocated")}</TextComponent>
<TextComponent>{formatCurrency(allocatedAmount, currency)}</TextComponent>
</div>
{(unallocatedAmount && unallocatedAmount !== 0) ? <div className="flex gap-2 justify-between">
<TextComponent>{_("Unallocated")}</TextComponent>
<Tooltip>
<TooltipTrigger asChild>
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={() => onAddRow(unallocatedAmount ?? 0)}>
<TextComponent className='text-ink-red-3'>{formatCurrency(unallocatedAmount, currency)}</TextComponent>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Add a charge to the payment entry with the unallocated amount")}
</TooltipContent>
</Tooltip>
</div> : null}
{(differenceAmount && differenceAmount !== 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(differenceAmount ?? 0)}>
<TextComponent className='text-ink-red-3'>{formatCurrency(differenceAmount, currency)}</TextComponent>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Add a charge to the payment entry with the difference amount")}
</TooltipContent>
</Tooltip>
</div> : null}
</div>
}
const GetUnpaidInvoicesButton = () => {
const [isOpen, setIsOpen] = useAtom(isUnpaidInvoicesButtonOpen)
const { control } = useFormContext<PaymentEntry>()
const partyType = useWatch({ control, name: 'party_type' })
const party = useWatch({ control, name: 'party' })
const partyName = useWatch({ control, name: 'party_name' })
const amount = useWatch({ control, name: 'paid_amount' })
return <>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{partyType && party && <DialogTrigger asChild>
<Button variant='outline' size='sm' type='button'>Get Unpaid Invoices</Button>
</DialogTrigger>}
<DialogContent className="min-w-[75vw]">
<DialogHeader>
<DialogTitle>Select Invoices</DialogTitle>
<DialogDescription>Unpaid invoices from {partyName} for {formatCurrency(amount)}.</DialogDescription>
</DialogHeader>
<FetchInvoicesModal onClose={() => setIsOpen(false)} />
</DialogContent>
</Dialog>
</>
}
interface OutstandingInvoice {
voucher_type: string
voucher_no: string
bill_no?: string
due_date: string
invoice_amount: number
outstanding_amount: number,
payment_term?: string,
payment_term_outstanding?: string,
account?: string,
allocated_amount?: number,
}
const FetchInvoicesModal = ({ onClose }: { onClose: () => void }) => {
const { getValues, setValue } = useFormContext<PaymentEntry>()
const { allocatePartyAmount } = usePaymentEntryCalculations()
const { data, isLoading, error } = useFrappeGetCall<{
message: OutstandingInvoice[],
_server_messages?: string
}>('erpnext.accounts.doctype.payment_entry.payment_entry.get_outstanding_reference_documents', {
args: {
company: getValues('company'),
posting_date: getValues('posting_date'),
party_type: getValues('party_type'),
party: getValues('party'),
party_account: getValues('payment_type') === 'Pay' ? getValues('paid_to') : getValues('paid_from'),
get_outstanding_invoices: true,
allocate_payment_amount: 1
}
})
const message = useMemo(() => {
if (data && data._server_messages) {
const message = JSON.parse(JSON.parse(data._server_messages)[0])
return message.message
}
return ''
}, [data])
const [selectedInvoices, setSelectedInvoices] = useState<OutstandingInvoice[]>([])
const onSelectRow = (row: OutstandingInvoice) => {
if (selectedInvoices.includes(row)) {
setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== row))
} else {
setSelectedInvoices([...selectedInvoices, row])
}
}
const { call: allocateAmountToReferences, loading: allocateAmountToReferencesLoading, error: allocateAmountToReferencesError } = useFrappePostCall('run_doc_method')
const onSelect = () => {
allocateAmountToReferences({
args: {
paid_amount: getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount"),
allocate_payment_amount: 1,
paid_amount_change: false
},
method: 'allocate_amount_to_references',
docs: {
doctype: 'Payment Entry',
...getValues(),
name: "new-payment-entry-1",
__unsaved: 1,
__islocal: 1,
references: selectedInvoices.map((ref: OutstandingInvoice) => ({
reference_doctype: ref.voucher_type,
reference_name: ref.voucher_no,
due_date: ref.due_date,
total_amount: ref.invoice_amount,
outstanding_amount: ref.outstanding_amount,
bill_no: ref.bill_no,
payment_term: ref.payment_term,
payment_term_outstanding: ref.payment_term_outstanding,
allocated_amount: ref.allocated_amount,
account: ref.account,
exchange_rate: 1,
}))
}
}).then((res) => {
const doc = res.docs[0]
setValue('references', doc.references)
setValue('unallocated_amount', doc.unallocated_amount)
setValue('total_allocated_amount', doc.total_allocated_amount)
setValue('difference_amount', doc.difference_amount)
allocatePartyAmount(getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount"))
onClose()
})
}
return <div className="flex flex-col gap-4">
{isLoading ? <TableLoader columns={6} /> : null}
{error && <ErrorBanner error={error} />}
{error && <ErrorBanner error={allocateAmountToReferencesError} />}
{message ? <MissingFiltersBanner text={<MarkdownRenderer content={message} />} /> : null}
{data?.message && data?.message?.length > 0 ? <Table>
<TableHeader>
<TableRow>
<TableHead>
<Checkbox checked={selectedInvoices.length === data?.message?.length} onCheckedChange={(checked) => {
if (checked) {
setSelectedInvoices(data?.message)
} else {
setSelectedInvoices([])
}
}} />
</TableHead>
<TableHead>
Type
</TableHead>
<TableHead>
Name
</TableHead>
<TableHead>
Invoice No
</TableHead>
<TableHead>
Due Date
</TableHead>
<TableHead className="text-end">
Grand Total
</TableHead>
<TableHead className="text-end">
Outstanding
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.message.map((ref) => (
<TableRow
key={ref.voucher_no}
onClick={(e) => {
const target = e.target as HTMLElement
// Do not select the checkbox if the user clicks on the checkbox or the link
if (target.tagName !== 'INPUT' && !target.className.includes('chakra-checkbox') && !target.className.includes('chakra-link')) {
onSelectRow(ref)
}
}}
className="cursor-pointer">
<TableCell>
<Checkbox checked={selectedInvoices.includes(ref)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedInvoices([...selectedInvoices, ref])
} else {
setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== ref))
}
}}
/>
</TableCell>
<TableCell>
{ref.voucher_type}
</TableCell>
<TableCell>
<a
target="_blank"
className="underline underline-offset-2"
href={`/desk/${slug(ref.voucher_type)}/${ref.voucher_no}`}>{ref.voucher_no}</a>
</TableCell>
<TableCell>
{ref.bill_no ?? "-"}
</TableCell>
<TableCell>
{formatDate(ref.due_date)}
</TableCell>
<TableCell className="text-end">
{formatCurrency(ref.invoice_amount)}
</TableCell>
<TableCell className="text-end font-medium">
{formatCurrency(ref.outstanding_amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table> : null}
<div className="flex justify-between items-center sticky bottom-0 bg-surface-modal">
<div className="flex gap-2">
<span className="text-ink-gray-5">Invoices: <span className="text-ink-gray-8 font-numeric font-medium">{selectedInvoices.length}</span></span> /
<span className="text-ink-gray-5">Total: <span className="text-ink-gray-8 font-numeric font-medium">{formatCurrency(selectedInvoices.reduce((acc, invoice) => acc + invoice.outstanding_amount, 0))}</span></span>
</div>
<DialogFooter className="pt-2">
<DialogClose asChild>
<Button variant='outline' size='md' disabled={allocateAmountToReferencesLoading}>Cancel</Button>
</DialogClose>
<Button onClick={onSelect} size='md' disabled={allocateAmountToReferencesLoading}>Select</Button>
</DialogFooter>
</div>
</div>
}
const OtherChargesSection = ({ currency }: { currency: string }) => {
const { setTotalAllocatedAmount } = usePaymentEntryCalculations()
const { getValues, control } = useFormContext<PaymentEntry>()
const { fields, append, remove } = useFieldArray({
control: control,
name: 'deductions'
})
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([])
setTotalAllocatedAmount()
}, [remove, selectedRows, setTotalAllocatedAmount])
const onAdd = () => {
append({
account: '',
cost_center: getCompanyCostCenter(getValues('company')),
description: '',
amount: 0
} as PaymentEntryDeduction)
}
return <div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<H4 className="text-base">Other Charges / Deductions</H4>
<TotalDeductions currency={currency} />
</div>
<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>{_("Account")} <span className="text-ink-red-3">*</span></TableHead>
<TableHead>{_("Cost Center")} <span className="text-ink-red-3">*</span></TableHead>
<TableHead>{_("Description")}</TableHead>
<TableHead className="text-end">{_("Amount")} <span className="text-ink-red-3">*</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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">
<AccountFormField
name={`deductions.${index}.account`}
label={_("Account")}
rules={{
required: _("Account is required"),
}}
buttonClassName="min-w-64"
isRequired
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<LinkFormField
doctype="Cost Center"
reference_doctype="Payment Entry Deduction"
customQuery={{
query: "erpnext.controllers.queries.get_filtered_dimensions",
filters: {
"dimension": "cost_center",
"company": getValues('company'),
}
}}
rules={{
required: _("Cost Center is required"),
}}
name={`deductions.${index}.cost_center`}
label={_("Cost Center")}
buttonClassName="min-w-48"
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<DataField
name={`entries.${index}.user_remark`}
label={_("Remarks")}
inputProps={{
placeholder: _("e.g. Bank Charges"),
className: 'min-w-64'
}}
hideLabel
/>
</TableCell>
<TableCell className="text-end align-top">
<CurrencyFormField
name={`deductions.${index}.amount`}
label={_("Amount")}
isRequired
hideLabel
currency={currency}
rules={{
onChange: () => {
setTotalAllocatedAmount()
}
}}
/>
</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>
}
const TotalDeductions = ({ currency }: { currency: string }) => {
const { control } = useFormContext<PaymentEntry>()
const total_deductions = useWatch({ control, name: 'deductions' })?.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0) ?? 0
return <span className={cn("font-numeric font-medium", total_deductions !== 0 ? "text-ink-red-3" : "text-ink-gray-5")}>({formatCurrency(total_deductions, currency)})</span>
}
export default RecordPaymentModal