Files
erpnext/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx
Nikhil Kothari 8447f551e7 fix(banking): use custom renderer for translated strings and parser for rules (#56643)
fix(banking): use custom renderer for translated strings and parser for formula evaluation
2026-06-30 09:21:42 +00:00

356 lines
15 KiB
TypeScript

import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react"
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { QueryReportReturnType } from "@/types/custom/Reports"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatCurrency } from "@/lib/numbers"
import { getCompanyCurrency } from "@/lib/company"
import { slug } from "@/lib/frappe"
import { CheckCircle2, ReceiptTextIcon, XCircle } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import { Badge } from "@/components/ui/badge"
import _ from "@/lib/translate"
import { useCopyToClipboard } from "usehooks-ts"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Form } from "@/components/ui/form"
import { useForm } from "react-hook-form"
import { DateField } from "@/components/ui/form-elements"
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankClearanceSummary = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
if (!bankAccount) {
return <MissingFiltersBanner text={_("Please select a bank account to view the bank clearance summary.")} />
}
if (!dates) {
return <MissingFiltersBanner text={_("Please select dates to view the bank clearance summary.")} />
}
return <BankClearanceSummaryView />
}
interface BankClearanceSummaryEntry {
payment_document_type: string
payment_entry: string
posting_date: string,
cheque_no?: string,
amount: number,
against: string,
clearance_date: string,
}
const BankClearanceSummaryView = () => {
const companyID = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const filters = useMemo(() => {
return JSON.stringify({
account: bankAccount?.account,
from_date: dates.fromDate,
to_date: dates.toDate
})
}, [bankAccount, dates])
const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType<BankClearanceSummaryEntry> }>('frappe.desk.query_report.run', {
report_name: 'Bank Clearance Summary',
filters,
ignore_prepared_report: 1,
are_default_filters: false,
}, `Report-Bank Clearance Summary-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
const formattedFromDate = formatDate(dates.fromDate)
const formattedToDate = formatDate(dates.toDate)
const [, copyToClipboard] = useCopyToClipboard()
const onCopy = useCallback(
(text: string) => {
copyToClipboard(text).then(() => {
toast.success(_("Copied to clipboard"))
})
},
[copyToClipboard],
)
const accountCurrency = useMemo(
() => bankAccount?.account_currency ?? getCompanyCurrency(companyID),
[bankAccount?.account_currency, companyID],
)
const clearanceColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
() => [
{
accessorKey: "payment_document_type",
header: _("Document Type"),
size: 140,
cell: ({ row }) => _(row.original.payment_document_type),
},
{
id: "payment_entry",
header: _("Payment Document"),
size: 160,
meta: {
getTooltipText: (r) => {
const x = r as BankClearanceSummaryEntry
return [x.payment_document_type, x.payment_entry].filter(Boolean).join(" · ") || undefined
},
} satisfies ListViewColumnMeta,
cell: ({ row }) => (
<a
target="_blank"
rel="noreferrer"
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
href={`/desk/${slug(row.original.payment_document_type)}/${row.original.payment_entry}`}
>
{row.original.payment_entry}
</a>
),
},
{
accessorKey: "posting_date",
header: _("Posting Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.posting_date),
},
{
accessorKey: "cheque_no",
header: _("Cheque/Reference Number"),
size: 160,
cell: ({ row }) => {
const ref = row.original.cheque_no ?? ""
return (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
className="text-ink-gray-8 hover:underline min-w-0 w-full cursor-pointer truncate text-start underline-offset-4"
onClick={() => onCopy(ref)}
>
{ref}
</button>
</TooltipTrigger>
<TooltipContent>
{ref}
</TooltipContent>
</Tooltip>
)
},
},
{
accessorKey: "clearance_date",
header: _("Clearance Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.clearance_date),
},
{
accessorKey: "against",
header: _("Against Account"),
size: 250,
},
{
accessorKey: "amount",
header: _("Amount"),
size: 150,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.amount, accountCurrency)}</span>,
},
{
id: "status",
header: _("Status"),
size: 200,
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
cell: ({ row }) => {
const r = row.original
return r.clearance_date ? (
<Badge theme="green">
<CheckCircle2 />
{_("Cleared")}
</Badge>
) : (
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Badge theme="red">
<XCircle />
{_("Not Cleared")}
</Badge>
<SetClearanceDateButton
voucher={r}
bankAccount={bankAccount}
companyID={companyID}
mutate={mutate}
/>
</div>
)
},
},
],
[accountCurrency, bankAccount, companyID, mutate, onCopy],
)
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
</div>
{error && <ErrorBanner error={error} />}
{data && data.message.result.length > 0 ? (
<ListView
data={data.message.result}
columns={clearanceColumns}
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
maxHeight="calc(100vh - 200px)"
scrollAreaClassName="min-h-[calc(100vh-200px)]"
emptyState={_("No rows to display.")}
/>
) : null}
{data && data.message.result.length == 0 &&
<Empty>
<EmptyMedia>
<ReceiptTextIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No entries found")}</EmptyTitle>
<EmptyDescription>{_("There are no accounting entries in the system for the selected account and dates.")}</EmptyDescription>
</EmptyHeader>
</Empty>
}
</div>
}
const SetClearanceDateButton = ({ voucher, bankAccount, companyID, mutate }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank | null, companyID: string, mutate: VoidFunction }) => {
const [open, setOpen] = useState(false)
const onClose = () => {
setOpen(false)
mutate()
}
return <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger disabled={!bankAccount}>
<Tooltip delayDuration={500}>
<TooltipTrigger>
<Button variant='link' size="sm" className="px-0" theme="red">{_("Force Clear")}</Button>
</TooltipTrigger>
<TooltipContent align='start'>
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent className="min-w-2xl">
{bankAccount && <ForceClearVoucherForm voucher={voucher} bankAccount={bankAccount} companyID={companyID} onClose={onClose} />}
</DialogContent>
</Dialog>
}
const ForceClearVoucherForm = ({ voucher, bankAccount, companyID, onClose }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank, companyID: string, onClose: () => void }) => {
const { mutate } = useSWRConfig()
const dates = useAtomValue(bankRecDateAtom)
const form = useForm<{ clearance_date: string }>({
defaultValues: {
clearance_date: voucher.posting_date,
}
})
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.update_clearance_date')
const onSubmit = (data: { clearance_date: string }) => {
call({
payment_document: voucher.payment_document_type,
payment_entry: voucher.payment_entry,
account: bankAccount.account,
clearance_date: data.clearance_date,
})
.then(() => {
toast.success(_("Clearance date updated"))
onClose()
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
})
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
<DialogHeader>
<DialogTitle>{_("Force Clear Voucher")}</DialogTitle>
<DialogDescription>
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
</DialogDescription>
</DialogHeader>
{error && <ErrorBanner error={error} />}
<div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Payment Document")}</TableHead>
<TableCell><a target="_blank" className="underline underline-offset-4"
href={`/desk/${slug(voucher.payment_document_type)}/${voucher.payment_entry}`}>{_(voucher.payment_document_type)} : {voucher.payment_entry}</a></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Posting Date")}</TableHead>
<TableCell>{formatDate(voucher.posting_date)}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Cheque/Reference Number")}</TableHead>
<TableCell title={voucher.cheque_no}>{voucher.cheque_no?.slice(0, 40)}{voucher.cheque_no?.length && voucher.cheque_no?.length > 40 ? "..." : ""}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Amount")}</TableHead>
<TableCell className="text-end">{formatCurrency(voucher.amount, bankAccount?.account_currency ?? getCompanyCurrency(companyID))}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Against Account")}</TableHead>
<TableCell><a target="_blank" className="underline underline-offset-4" href={`/desk/account/${voucher.against}`}>{voucher.against}</a></TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
<DateField
name='clearance_date'
label={_("Clearance Date")}
isRequired
inputProps={{ autoFocus: true }}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} disabled={loading} size='md'>{_("Cancel")}</Button>
</DialogClose>
<Button type='submit' disabled={loading} size='md'>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
export default BankClearanceSummary