Files
erpnext/banking/src/pages/BankStatementImporter.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

255 lines
12 KiB
TypeScript

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