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
This commit is contained in:
Nikhil Kothari
2026-05-09 23:14:58 +05:30
committed by GitHub
parent 332026fe5e
commit 6de5367f12
262 changed files with 39467 additions and 14 deletions

View File

@@ -0,0 +1,140 @@
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
import BankReconciliationStatement from "@/components/features/BankReconciliation/BankReconciliationStatement"
import BankTransactions from "@/components/features/BankReconciliation/BankTransactionList"
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
import Settings from "@/components/features/Settings/Settings"
import ActionLog from "@/components/features/ActionLog/ActionLog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TooltipProvider } from "@/components/ui/tooltip"
import _ from "@/lib/translate"
import { useLayoutEffect, useRef, useState } from "react"
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Button } from "@/components/ui/button"
import { useAtomValue } from "jotai"
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
const BankReconciliation = () => {
const [headerHeight, setHeaderHeight] = useState(0)
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (ref.current) {
setHeaderHeight(ref.current.clientHeight)
}
}, [])
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
return (
<div>
<div className="p-4 flex-col gap-4 md:flex hidden">
<div ref={ref} className="flex flex-col gap-4">
<div className="flex justify-between">
<div className="flex items-center gap-6">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<a href="/desk" className="text-ink-gray-7">
<HomeIcon size={16} />
</a>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
<div className="flex gap-1 items-center">
{_("Banking")} <Badge theme="violet" variant="subtle">{_("Beta")}</Badge>
</div>
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<CompanySelector />
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Settings />
<ActionLog />
</TooltipProvider>
<BankRecDateFilter />
</div>
</div>
<BankPicker />
<BankBalance />
</div>
<BankRecTabs remainingHeightAfterTabs={remainingHeightAfterTabs} />
<BankTransactionUnreconcileModal />
</div>
<div className="md:hidden flex h-screen items-center justify-between">
<Empty>
<EmptyMedia>
<LandmarkIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>
{_("Banking")}
</EmptyTitle>
<EmptyDescription>
{_("This screen is not supported on mobile devices.")}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild>
<a href="/desk">
{_("Go to Desktop")}
</a>
</Button>
</EmptyContent>
</Empty>
</div>
</div>
)
}
const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: number }) => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
if (!selectedBankAccount) {
return null
}
return <Tabs defaultValue="Match and Reconcile">
<TabsList>
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Bank Reconciliation Statement")}</TabsTrigger>
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Bank Transactions")}</TabsTrigger>
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Bank Clearance Summary")}</TabsTrigger>
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared Entries")}</TabsTrigger>
</TabsList>
<TabsContent value="Match and Reconcile">
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
</TabsContent>
<TabsContent value="Bank Reconciliation Statement">
<BankReconciliationStatement />
</TabsContent>
<TabsContent value="Bank Transactions">
<BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries">
<IncorrectlyClearedEntries />
</TabsContent>
</Tabs>
}
export default BankReconciliation

View File

@@ -0,0 +1,255 @@
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import ErrorBanner from "@/components/ui/error-banner"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { H3, Paragraph } from "@/components/ui/typography"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { formatDate } from "@/lib/date"
import { flt, formatCurrency } from "@/lib/numbers"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList } from "frappe-react-sdk"
import { useAtom, useAtomValue } from "jotai"
import { ListIcon, Loader2Icon } from "lucide-react"
import { useState } from "react"
import { useNavigate } from "react-router"
const BankStatementImporter = () => {
const selectedCompany = useCurrentCompany()
const [selectedBankAccount] = useAtom(selectedBankAccountAtom)
const [files, setFiles] = useState<File[]>([])
const { upload, error, loading } = useFrappeFileUpload()
const navigate = useNavigate()
const { createDoc, loading: createLoading, error: createError } = useFrappeCreateDoc<BankStatementImportLog>()
const onUpload = () => {
if (!selectedBankAccount) {
return
}
const id = `new-bank-statement-import-log-${Date.now()}`
upload(files[0], {
isPrivate: true,
doctype: "Bank Statement Import Log",
docname: id,
fieldname: 'file'
}).then((file) => {
return createDoc("Bank Statement Import Log",
// @ts-expect-error - not filling everything else
{
name: id,
file: file.file_url,
bank_account: selectedBankAccount.name
})
}).then((doc) => {
navigate(`/statement-importer/${doc.name}`)
})
}
return (
<div className="flex px-4">
<div className="w-[52%]">
{error && <ErrorBanner error={error} />}
{createError && <ErrorBanner error={createError} />}
<div className="py-2 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label>{_("Company")}<span className="text-ink-red-3">*</span></Label>
<div className="min-w-56 w-fit flex flex-col">
<CompanySelector />
</div>
</div>
{selectedCompany && <div className="flex flex-col gap-2">
<Label>{_("Bank Account")}<span className="text-ink-red-3">*</span></Label>
<div className="">
<BankPicker className="w-full flex-wrap" />
</div>
</div>
}
{selectedBankAccount && <div className="flex flex-col gap-4 pe-4">
<div className="flex justify-between">
<div className="flex flex-col gap-2">
<Label>{_("Bank Statement")}<span className="text-ink-red-3">*</span></Label>
<p
data-slot="form-description"
className={cn("text-ink-gray-5 text-xs")}
>
{_("Upload your bank statement file to start the import process. We support CSV, and XLSX files.")}
</p>
</div>
<div>
<StatementInstructions />
</div>
</div>
<FileDropzone
setFiles={setFiles}
files={files}
className="p-8"
accept={{
'text/csv': ['.csv'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
// 'application/xml': ['.xml'],
}}
multiple={false}
/>
</div>}
<div className="flex justify-end px-4">
<Button
onClick={onUpload}
size='md'
disabled={files.length === 0 || loading || createLoading || !selectedBankAccount || !selectedCompany}>
{loading || createLoading ? <Loader2Icon className="size-4 animate-spin" /> : null}
{loading || createLoading ? _("Uploading...") : _("Upload")}
</Button>
</div>
</div>
</div>
<div className="w-[48%] border-s border-outline-gray-2 ps-4">
{selectedBankAccount && <StatementImportLog />}
</div>
</div>
)
}
const StatementInstructions = () => {
return <Dialog>
<DialogTrigger asChild>
<Button variant='outline' size='sm'>{_("View Instructions")}</Button>
</DialogTrigger>
<DialogContent className="min-w-7xl">
<DialogHeader>
<DialogTitle>{_("Statement Import Instructions")}</DialogTitle>
<DialogDescription>{_("We support uploading CSV, XLSX and XLS files. Please make sure the file contains the correct columns.")}</DialogDescription>
</DialogHeader>
<Paragraph className="text-sm">{_("The file should contain the following columns with a distinct header row. You can upload most bank statements as is without changing the columns.")}</Paragraph>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Column Name")}</TableHead>
<TableHead>{_("Maps To")}</TableHead>
<TableHead>{_("Description")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Date/Transaction Date/Value Date</TableCell>
<TableCell>{_("Date")}</TableCell>
<TableCell className="text-ink-gray-5">{_("The date of the transaction")}</TableCell>
</TableRow>
<TableRow>
<TableCell>Amount</TableCell>
<TableCell>{_("Amount")}</TableCell>
<TableCell className="text-ink-gray-5">{_('This can contain "CR"/"DR" values or positive/negative values. You could also have a separate column for CR/DR.')}</TableCell>
</TableRow>
<TableRow>
<TableCell>Withdrawal/Deposit</TableCell>
<TableCell>{_("Withdrawal")}/{_("Deposit")}</TableCell>
<TableCell className="text-ink-gray-5">{_("The withdrawal or deposit amounts - only required if there's no amount column.")}</TableCell>
</TableRow>
<TableRow>
<TableCell>Description/Particulars/Remarks/Narration/Detail</TableCell>
<TableCell>{_("Description")}</TableCell>
<TableCell className="text-ink-gray-5">{_("The description of the transaction")}</TableCell>
</TableRow>
<TableRow>
<TableCell>Reference/Ref/Transaction ID/Cheque/Check</TableCell>
<TableCell>{_("Reference")}</TableCell>
<TableCell className="text-ink-gray-5">{_("The reference number of the transaction")}</TableCell>
</TableRow>
</TableBody>
</Table>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline'>{_("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
}
const StatementImportLog = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const { data, error } = useFrappeGetDocList<BankStatementImportLog>("Bank Statement Import Log", {
fields: ["name", "file", "status", "number_of_transactions", "start_date", "end_date", "closing_balance", "creation"],
filters: [["bank_account", "=", bankAccount?.name ?? ""]],
orderBy: {
field: "creation",
order: "desc"
},
limit: 10
}, bankAccount ? undefined : null, {
revalidateOnFocus: false
})
const navigate = useNavigate()
const onViewDetails = (name: string) => {
navigate(`/statement-importer/${name}`)
}
return (
<div className="flex flex-col gap-4">
<H3 className="text-base">{_("Previous Imports")}</H3>
{error && <ErrorBanner error={error} />}
{data && data.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Imported On")}</TableHead>
<TableHead>{_("Status")}</TableHead>
<TableHead>{_("Transaction Dates")}</TableHead>
<TableHead className="text-end">{_("Number of Transactions")}</TableHead>
<TableHead className="text-end">{_("Closing Balance")}</TableHead>
<TableHead>{_("File")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((item) => (
<TableRow key={item.name} onClick={() => onViewDetails(item.name)} className="cursor-pointer hover:bg-surface-gray-2">
<TableCell>{formatDate(item.creation, 'Do MMM YYYY')}</TableCell>
<TableCell><Badge theme={item.status === "Completed" ? "green" : "gray"}>{item.status}</Badge></TableCell>
<TableCell>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</TableCell>
<TableCell className="text-end">{item.number_of_transactions}</TableCell>
<TableCell className="text-end font-numeric">{formatCurrency(flt(item.closing_balance, 2))}</TableCell>
<TableCell><a
href={item.file}
target="_blank" className="underline underline-offset-4">{item.file.split('/').pop()}</a></TableCell>
</TableRow>
))}
</TableBody>
</Table>)
: <Empty>
<EmptyHeader>
<EmptyMedia>
<ListIcon />
</EmptyMedia>
<EmptyTitle>{_("No bank statements imported yet")}</EmptyTitle>
</EmptyHeader>
</Empty>}
</div>
)
}
export default BankStatementImporter

View File

@@ -0,0 +1,37 @@
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
import _ from '@/lib/translate'
import { HomeIcon } from 'lucide-react'
import { Link, Outlet } from 'react-router'
const BankStatementImporterContainer = () => {
return (
<div className="flex flex-col pt-1.5">
<div className="flex gap-2 items-baseline p-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<a href="/desk" className="text-ink-gray-7">
<HomeIcon size={16} />
</a>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/">
{_("Banking")}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{_("Import Bank Statement")}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<Outlet />
</div>
)
}
export default BankStatementImporterContainer

View File

@@ -0,0 +1,46 @@
import CSVImport from '@/components/features/BankStatementImporter/CSV/CSVImport'
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
import { Button } from '@/components/ui/button'
import { useDirection } from '@/components/ui/direction'
import ErrorBanner from '@/components/ui/error-banner'
import _ from '@/lib/translate'
import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { Link, useParams } from 'react-router'
const ViewBankStatementImportLog = () => {
const { id } = useParams<{ id: string }>()
const { data, isLoading, error } = useGetStatementDetails(id ?? "")
useFrappeDocumentEventListener("Bank Statement Import Log", id ?? "", () => {
})
const direction = useDirection()
if (!data || !data.message) {
return null
}
if (isLoading) {
return <div>Loading...</div>
}
if (error) {
return <div className='flex flex-col gap-4 px-4'>
<div>
<Button size='sm' variant='outline' asChild>
<Link to="/statement-importer">
{direction === 'ltr' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
{_("Back")}
</Link>
</Button>
</div>
<ErrorBanner error={error} />
</div>
}
return <CSVImport data={data} />
}
export default ViewBankStatementImportLog