mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-13 18:21:22 +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
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
import { useAtomValue, useSetAtom } from "jotai"
|
|
import { bankRecClosingBalanceAtom, bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
|
import { FrappeConfig, FrappeContext, useFrappeGetDocCount, useFrappeGetDocList, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
|
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
|
import { Progress } from "@/components/ui/progress"
|
|
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
|
|
import { flt, formatCurrency } from "@/lib/numbers"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
|
|
import { Edit, Info, Trash2 } from "lucide-react"
|
|
import { H4, Paragraph } from "@/components/ui/typography"
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
|
import { getCompanyCurrency } from "@/lib/company"
|
|
import _ from "@/lib/translate"
|
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import { formatDate } from "@/lib/date"
|
|
import { Form } from "@/components/ui/form"
|
|
import { CurrencyFormField } from "@/components/ui/form-elements"
|
|
import { useForm } from "react-hook-form"
|
|
import { Button } from "@/components/ui/button"
|
|
import { useContext, useState } from "react"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { BankAccountBalance } from "@/types/Accounts/BankAccountBalance"
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
import { toast } from "sonner"
|
|
import ErrorBanner from "@/components/ui/error-banner"
|
|
|
|
const BankBalance = () => {
|
|
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
|
|
if (!bankAccount) {
|
|
return null
|
|
}
|
|
return (
|
|
<div className="flex justify-between">
|
|
<div className="w-[80%] flex flex-wrap justify-between gap-2 pe-8 border-e-border border-e">
|
|
<OpeningBalance />
|
|
<ClosingBalance />
|
|
<ClosingBalanceAsPerStatement />
|
|
<Difference />
|
|
</div>
|
|
|
|
<ReconcileProgress />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const OpeningBalance = () => {
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
const { data, isLoading } = useGetAccountOpeningBalance()
|
|
|
|
return <StatContainer className="min-w-48">
|
|
<StatLabel>{_("Opening Balance")}</StatLabel>
|
|
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
|
</StatContainer>
|
|
}
|
|
|
|
const ClosingBalance = () => {
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
const { data, isLoading } = useGetAccountClosingBalance()
|
|
|
|
return (
|
|
<StatContainer className="min-w-48">
|
|
<div className="flex items-start gap-1">
|
|
<StatLabel>
|
|
{_("Closing Balance as per system")}
|
|
</StatLabel>
|
|
<HoverCard openDelay={100}>
|
|
<HoverCardTrigger>
|
|
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
|
|
</HoverCardTrigger>
|
|
<HoverCardContent className="w-96" align="start" side="right">
|
|
<H4 className="text-base">{_("Closing balance as per system")}</H4>
|
|
<Paragraph className="mt-2 text-p-sm">
|
|
{_("This is what the system expects the closing balance to be in your bank statement.")}
|
|
<br />
|
|
{_("It takes into account all the transactions that have been posted and subtracts the transactions that have not cleared yet.")}
|
|
<br />
|
|
{_("If your bank statement shows a different closing balance, it is because all transactions have not reconciled yet.")}
|
|
<br /><br />
|
|
For more information, click on the <strong>Bank Reconciliation Statement</strong> tab below.
|
|
</Paragraph>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
|
|
</div>
|
|
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
|
</StatContainer>
|
|
)
|
|
}
|
|
|
|
const Difference = () => {
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
|
|
const { data, isLoading } = useGetAccountClosingBalance()
|
|
|
|
const value = useAtomValue(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
|
|
|
const difference = flt(value.value - (data?.message ?? 0))
|
|
|
|
const isError = difference !== 0
|
|
|
|
return <StatContainer className="w-fit text-end sm:min-w-56">
|
|
<StatLabel className="text-end">{_("Difference")}</StatLabel>
|
|
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
|
|
{formatCurrency(difference,
|
|
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
|
|
}</StatValue>}
|
|
</StatContainer>
|
|
}
|
|
|
|
const ReconcileProgress = () => {
|
|
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
|
|
const { data: totalCount } = useFrappeGetDocCount<BankTransaction>('Bank Transaction', [
|
|
["bank_account", "=", bankAccount?.name ?? ''],
|
|
['docstatus', '=', 1],
|
|
['date', '<=', dates?.toDate],
|
|
['date', '>=', dates?.fromDate]
|
|
], false, undefined, {
|
|
revalidateOnFocus: false
|
|
})
|
|
|
|
const { data: unreconciledTransactions, } = useGetUnreconciledTransactions()
|
|
|
|
const reconciledCount = (totalCount ?? 0) - (unreconciledTransactions?.message?.length ?? 0)
|
|
|
|
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
|
|
|
|
return <div className="w-[18%] flex flex-col gap-1 items-end">
|
|
<div className="w-full">
|
|
<Progress
|
|
value={progress}
|
|
max={100}
|
|
size="md"
|
|
label="Progress"
|
|
hint
|
|
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
const ClosingBalanceAsPerStatement = () => {
|
|
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
|
|
|
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
|
|
onSuccess: (data) => {
|
|
if (data?.message && data?.message?.balance) {
|
|
setValue({
|
|
value: data?.message?.balance,
|
|
stringValue: data?.message?.balance.toString()
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
const isDateSame = data?.message?.date === dates.toDate
|
|
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
|
|
|
|
return <StatContainer className="min-w-48">
|
|
<StatLabel>{_("Closing Balance as per statement")}</StatLabel>
|
|
<div className="flex flex-col gap-2 items-start">
|
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
<DialogTrigger>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
|
|
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
|
<Edit className="w-4 h-4" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{_("Click to set the closing balance as per statement")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</DialogTrigger>
|
|
<DialogContent className="min-w-xl">
|
|
<ClosingBalanceForm
|
|
defaultBalance={data?.message?.balance ?? 0}
|
|
date={dates.toDate}
|
|
bankAccount={bankAccount}
|
|
onClose={() => setIsOpen(false)}
|
|
/>
|
|
|
|
|
|
</DialogContent>
|
|
</Dialog>
|
|
{!isDateSame && data?.message.date && <span className="text-xs font-medium text-ink-red-3">{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}</span>}
|
|
</div>
|
|
</StatContainer>
|
|
|
|
}
|
|
|
|
const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
|
|
|
|
const { mutate } = useSWRConfig()
|
|
|
|
const form = useForm<{ balance: number }>({
|
|
defaultValues: {
|
|
balance: defaultBalance
|
|
}
|
|
})
|
|
|
|
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
|
|
|
const { call, loading, error } = useFrappePostCall("erpnext.accounts.doctype.bank_account.bank_account.set_closing_balance_as_per_statement")
|
|
|
|
const onSubmit = (data: { balance: number }) => {
|
|
if (data.balance) {
|
|
call({
|
|
bank_account: bankAccount?.name ?? '',
|
|
date: date,
|
|
balance: data.balance
|
|
})
|
|
.then(() => {
|
|
// Mutate the closing balance as per statement
|
|
mutate(`bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${date}`)
|
|
setValue({
|
|
value: data.balance,
|
|
stringValue: data.balance.toString()
|
|
})
|
|
toast.success(_("Closing balance set."))
|
|
onClose()
|
|
|
|
|
|
})
|
|
} else {
|
|
toast.error(_("Closing balance is required."))
|
|
}
|
|
}
|
|
|
|
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
|
|
|
|
|
return <Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
<DialogHeader>
|
|
<DialogTitle>{_("Set closing balance as per bank statement")}</DialogTitle>
|
|
<DialogDescription>
|
|
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{error && <ErrorBanner error={error} />}
|
|
<div className="py-4">
|
|
<CurrencyFormField
|
|
name="balance"
|
|
label={_("Closing balance on bank statement as of {0}", [formatDate(date, 'Do MMM YYYY')])}
|
|
isRequired
|
|
currency={currency}
|
|
/>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant={'outline'} size='md' disabled={loading}>{_("Cancel")}</Button>
|
|
</DialogClose>
|
|
<Button type='submit' size='md' disabled={loading}>{_("Save")}</Button>
|
|
</DialogFooter>
|
|
|
|
<ClosingBalancesList bankAccount={bankAccount} date={date} />
|
|
</form>
|
|
</Form>
|
|
}
|
|
|
|
const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank | null, date: string }) => {
|
|
|
|
const { data, mutate } = useFrappeGetDocList<BankAccountBalance>("Bank Account Balance", {
|
|
filters: [["bank_account", "=", bankAccount?.name ?? ''], ["date", "<=", date]],
|
|
orderBy: {
|
|
field: "date",
|
|
order: "desc"
|
|
},
|
|
fields: ["date", "balance", "name"],
|
|
limit: 10
|
|
})
|
|
|
|
const { db } = useContext(FrappeContext) as FrappeConfig
|
|
|
|
const onDelete = (name: string) => {
|
|
toast.promise(db.deleteDoc("Bank Account Balance", name).then(() => {
|
|
mutate()
|
|
}), {
|
|
loading: _("Deleting closing balance..."),
|
|
success: _("Closing balance deleted."),
|
|
error: _("Failed to delete closing balance.")
|
|
})
|
|
}
|
|
|
|
if (data?.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return <div>
|
|
<Separator className="my-8" />
|
|
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{_("Date")}</TableHead>
|
|
<TableHead className="text-end">{_("Balance")}</TableHead>
|
|
<TableHead></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data?.map((item) => (
|
|
<TableRow key={item.name}>
|
|
<TableCell>{formatDate(item.date, 'Do MMM YYYY')}</TableCell>
|
|
<TableCell className="text-end">{formatCurrency(flt(item.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</TableCell>
|
|
<TableCell className="text-end">
|
|
<Button
|
|
title={_("Delete")}
|
|
type='button' isIconButton variant='ghost' onClick={() => onDelete(item.name)}>
|
|
<Trash2 />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
}
|
|
|
|
export default BankBalance |