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
832 lines
35 KiB
TypeScript
832 lines
35 KiB
TypeScript
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
|