fix(banking): UI cleanup and better statement parsing (backport #58817) (#58824)

fix(banking): UI cleanup and better statement parsing (#58817)

* fix(banking): reset scroll on searching accounts

* fix(banking): show only past dates in date filter

* fix(banking): clean up line heights and remove beta badge

* fix(banking): show accurate count of import progress
fix(banking): show latest 20 imports instead of 10

* fix(banking): layout sizing needs to be preserved on page change

* fix(banking): cleaner bank balance UI

* fix(banking): correctly parse Cr/Dr values in statement importer

* Update banking/src/components/features/BankReconciliation/BankBalance.tsx



---------


(cherry picked from commit ebe5decb96)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
mergify[bot]
2026-09-07 20:31:45 +05:30
committed by GitHub
parent 817926ca2e
commit 0610708d78
22 changed files with 1162 additions and 437 deletions

View File

@@ -9,6 +9,7 @@ import Fuse from "fuse.js"
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react"
import { useLayoutEffect, useMemo, useRef, useState } from "react" import { useLayoutEffect, useMemo, useRef, useState } from "react"
import { FormControl } from "../ui/form" import { FormControl } from "../ui/form"
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch"
export interface AccountsDropdownProps { export interface AccountsDropdownProps {
@@ -104,6 +105,10 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
// Searching replaces the grouped list with a short result list, so pin the scroll back to
// the top - otherwise the auto-selected first result can be out of view.
const listRef = useResetScrollOnSearch(search)
const [width, setWidth] = useState(320) const [width, setWidth] = useState(320)
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -153,7 +158,7 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
<PopoverContent className="p-0" style={{ minWidth: width }} align="start"> <PopoverContent className="p-0" style={{ minWidth: width }} align="start">
<Command shouldFilter={false} className="w-full"> <Command shouldFilter={false} className="w-full">
<CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} /> <CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} />
<CommandList> <CommandList ref={listRef}>
<CommandEmpty>{_("No accounts found.")}</CommandEmpty> <CommandEmpty>{_("No accounts found.")}</CommandEmpty>
{recommendedAccounts.length > 0 && ( {recommendedAccounts.length > 0 && (

View File

@@ -10,6 +10,7 @@ import { ChevronDownIcon, ExternalLink } from "lucide-react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command";
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch";
import _ from "@/lib/translate"; import _ from "@/lib/translate";
import ErrorBanner from "../ui/error-banner"; import ErrorBanner from "../ui/error-banner";
import MarkdownRenderer from "../ui/markdown"; import MarkdownRenderer from "../ui/markdown";
@@ -149,6 +150,10 @@ const LinkFieldCombobox = ({
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
// Results change as the search runs, so pin the scroll back to the top to keep the
// auto-selected first result in view.
const listRef = useResetScrollOnSearch(searchInput)
const [width, setWidth] = useState(320) const [width, setWidth] = useState(320)
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -264,7 +269,7 @@ const LinkFieldCombobox = ({
{error && <ErrorBanner error={error} />} {error && <ErrorBanner error={error} />}
<Command shouldFilter={false} className="w-full"> <Command shouldFilter={false} className="w-full">
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} /> <CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
<CommandList> <CommandList ref={listRef}>
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty> <CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{items?.map((result) => ( {items?.map((result) => (
@@ -272,7 +277,7 @@ const LinkFieldCombobox = ({
<span className="font-medium"> <span className="font-medium">
{result.label || result.value} {result.label || result.value}
</span> </span>
{result.description && <span className="text-xs text-ink-gray-5"> {result.description && <span className="text-p-xs text-ink-gray-5">
<MarkdownRenderer content={result.description} /> <MarkdownRenderer content={result.description} />
</span>} </span>}
</CommandItem> </CommandItem>

View File

@@ -6,13 +6,13 @@ import { Progress } from "@/components/ui/progress"
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils" import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
import { flt, formatCurrency } from "@/lib/numbers" import { flt, formatCurrency } from "@/lib/numbers"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
import { Edit, Info, Trash2 } from "lucide-react" import { Edit, Info, Trash2 } from "lucide-react"
import { H4, Paragraph } from "@/components/ui/typography" import { H4, Paragraph } from "@/components/ui/typography"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card" import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { getCompanyCurrency } from "@/lib/company" import { getCompanyCurrency } from "@/lib/company"
import _ from "@/lib/translate" import _ from "@/lib/translate"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { cn } from "@/lib/utils"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { formatDate } from "@/lib/date" import { formatDate } from "@/lib/date"
import { Form } from "@/components/ui/form" import { Form } from "@/components/ui/form"
@@ -26,50 +26,109 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "sonner" import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner" import ErrorBanner from "@/components/ui/error-banner"
const BankBalance = () => { const useBankCurrency = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
return bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
}
/**
* One line of the balance summary - label on the left, figure right-aligned.
*
* `items-baseline` keeps the figure on the label's FIRST line, so a row carrying a `subLabel`
* (the statement row's "As of <date>" note) doesn't centre its value against both lines.
*/
const BalanceRow = ({ label, info, subLabel, emphasis, children }: {
label: React.ReactNode
info?: React.ReactNode
subLabel?: React.ReactNode
emphasis?: boolean
children: React.ReactNode
}) => (
<div className="flex items-baseline justify-between gap-3">
<span className="flex min-w-0 flex-col gap-1.5">
<span className={cn("flex items-center gap-1 whitespace-nowrap text-xs text-ink-gray-6",
emphasis && "font-medium text-ink-gray-7")}>
{label}
{info}
</span>
{subLabel}
</span>
<div className="flex flex-col items-end">{children}</div>
</div>
)
/**
* Type styles for a figure. Shared so an interactive figure can put them on the <button>
* ITSELF rather than on a nested span: Tailwind's preflight sets `font: inherit` on buttons,
* which resets line-height too, so a button wrapping a `text-sm` span gets a taller strut than
* the span and the row grows - visible as extra space above a baseline-aligned row.
*/
const BALANCE_VALUE_CLASSES = "font-numeric text-sm tabular-nums text-ink-gray-8"
const BalanceValue = ({ children, emphasis, tone, className }: { children: React.ReactNode, emphasis?: boolean, tone?: 'red', className?: string }) => (
<span className={cn(BALANCE_VALUE_CLASSES,
emphasis && "font-semibold",
tone === 'red' && "text-ink-red-3",
className)}>
{children}
</span>
)
const BalanceSkeleton = () => <Skeleton className="h-4 w-24 rounded-sm" />
/**
* Balances and progress for the selected bank account, laid out like the totals block of an
* invoice. This sits beside the bank picker rather than in a row of its own (saves vertical
* space) and outside the picker's horizontal scroll area, so the figures being reconciled
* against can never scroll out of view.
*/
const BankAccountBalancePanel = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom) const bankAccount = useAtomValue(selectedBankAccountAtom)
if (!bankAccount) { if (!bankAccount) {
return null 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 /> return (
<div className="flex w-72 shrink-0 flex-col justify-center gap-2.5 border-s border-outline-gray-2 ps-4">
{/* Names the account these figures belong to - the picker scrolls, so the
highlighted card can't be relied on as the referent. */}
<span
className="truncate text-xs font-medium text-ink-gray-7"
title={bankAccount.account_name}>
{bankAccount.account_name}
</span>
<OpeningBalanceRow />
<SystemClosingBalanceRow />
<StatementClosingBalanceRow />
<Separator />
<DifferenceRow />
<ReconciledRow />
</div> </div>
) )
} }
const OpeningBalance = () => { const OpeningBalanceRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom) const currency = useBankCurrency()
const { data, isLoading } = useGetAccountOpeningBalance() const { data, isLoading } = useGetAccountOpeningBalance()
return <StatContainer className="min-w-48"> return <BalanceRow label={_("Opening Balance")}>
<StatLabel>{_("Opening Balance")}</StatLabel> {isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>} </BalanceRow>
</StatContainer>
} }
const ClosingBalance = () => { const SystemClosingBalanceRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom) const currency = useBankCurrency()
const { data, isLoading } = useGetAccountClosingBalance() const { data, isLoading } = useGetAccountClosingBalance()
return ( return (
<StatContainer className="min-w-48"> <BalanceRow
<div className="flex items-start gap-1"> label={_("Closing (system)")}
<StatLabel> info={
{_("Closing Balance as per system")}
</StatLabel>
<HoverCard openDelay={100}> <HoverCard openDelay={100}>
<HoverCardTrigger> <HoverCardTrigger>
<Info className="size-3.5 text-ink-gray-6 -mt-px" /> <Info className="size-3.5 text-ink-gray-6" />
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-96" align="start" side="right"> <HoverCardContent className="w-96" align="start" side="right">
<H4 className="text-base">{_("Closing balance as per system")}</H4> <H4 className="text-base">{_("Closing balance as per system")}</H4>
@@ -84,15 +143,111 @@ const ClosingBalance = () => {
</Paragraph> </Paragraph>
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
}
</div> >
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>} {isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
</StatContainer> </BalanceRow>
) )
} }
const Difference = () => { const StatementClosingBalanceRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom) const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = useBankCurrency()
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
// The server uses the returned date to distinguish an unset balance from a saved zero.
const hasBalance = Boolean(data?.message?.date)
const [isOpen, setIsOpen] = useState(false)
const tooltip = hasBalance
? _("Click to change the closing balance as per statement")
: _("Click to set the closing balance as per statement")
return (
<BalanceRow
label={_("Closing (statement)")}
// The pencil sits beside the label, mirroring the info icon on the row above, so
// the figure stays a plain right-aligned number in line with every other row.
info={
<Tooltip>
<TooltipTrigger asChild>
{/* `p-0`: Tailwind's preflight gives buttons `appearance: button` but
doesn't reset padding, so a bare button picks up the UA's ~1px 6px
and knocks this row out of step with its neighbours. */}
<button
type='button'
aria-label={tooltip}
onClick={() => setIsOpen(true)}
className="cursor-pointer p-0 text-ink-gray-5 transition-colors hover:text-ink-gray-7">
<Edit className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
}
subLabel={!isDateSame && data?.message.date
? <span className="whitespace-nowrap text-2xs font-medium text-ink-red-3">
{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}
</span>
: undefined}
>
{/* Deliberately NOT a flex container: a flex box's baseline doesn't resolve to its
text, so the row's `items-baseline` couldn't line this up with the label. As a
plain inline button its baseline is the figure's own, like every other row.
"Set" gets the same treatment as a figure - it stands in for one. */}
{isLoading
? <BalanceSkeleton />
: <Tooltip>
<TooltipTrigger asChild>
{/* The figure styles live on the button itself - see
BALANCE_VALUE_CLASSES. `p-0` because preflight leaves the UA's
button padding in place. */}
<button
type='button'
aria-label={tooltip}
onClick={() => setIsOpen(true)}
className={cn(BALANCE_VALUE_CLASSES,
"cursor-pointer p-0 underline decoration-outline-gray-5 decoration-dashed underline-offset-4",
"transition-colors hover:decoration-ink-gray-8")}>
{hasBalance ? formatCurrency(flt(data?.message?.balance, 2), currency) : _("Set")}
</button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="min-w-xl">
<ClosingBalanceForm
defaultBalance={data?.message?.balance ?? 0}
date={dates.toDate}
bankAccount={bankAccount}
onClose={() => setIsOpen(false)}
/>
</DialogContent>
</Dialog>
</BalanceRow>
)
}
const DifferenceRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = useBankCurrency()
const { data, isLoading } = useGetAccountClosingBalance() const { data, isLoading } = useGetAccountClosingBalance()
@@ -102,16 +257,15 @@ const Difference = () => {
const isError = difference !== 0 const isError = difference !== 0
return <StatContainer className="w-fit text-end sm:min-w-56"> return <BalanceRow label={_("Difference")} emphasis>
<StatLabel className="text-end">{_("Difference")}</StatLabel> {isLoading
{isLoading ? <Skeleton className="w-[150px] h-5 self-end rounded-sm" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}> ? <BalanceSkeleton />
{formatCurrency(difference, : <BalanceValue emphasis tone={isError ? 'red' : undefined}>{formatCurrency(difference, currency)}</BalanceValue>}
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')) </BalanceRow>
}</StatValue>}
</StatContainer>
} }
const ReconcileProgress = () => { /** Reconciliation progress through the selected date range: a count plus a slim bar. */
const ReconciledRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom) const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -132,75 +286,14 @@ const ReconcileProgress = () => {
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100 const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
return <div className="w-[18%] flex flex-col gap-1 items-end"> return <div className="flex flex-col gap-1.5">
<div className="w-full"> <BalanceRow label={_("Reconciled")}>
<Progress <BalanceValue>{reconciledCount} / {totalCount ?? 0}</BalanceValue>
value={progress} </BalanceRow>
max={100} <Progress value={progress} max={100} size="sm" />
size="md"
label="Progress"
hint
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
</div>
</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-5 rounded-sm" /> : <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 ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
const { mutate } = useSWRConfig() const { mutate } = useSWRConfig()
@@ -302,7 +395,7 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
return <div> return <div>
<Separator className="my-8" /> <Separator className="my-8" />
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p> <p className="text-p-sm text-center pb-2">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -331,4 +424,4 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
} }
export default BankBalance export default BankAccountBalancePanel

View File

@@ -205,9 +205,9 @@ const BankClearanceSummaryView = () => {
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>`]) 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"> return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
<div> <div className="shrink-0">
<span className="text-p-sm"> <span className="text-p-sm">
<MarkdownRenderer content={content} /> <MarkdownRenderer content={content} />
</span> </span>
@@ -220,8 +220,9 @@ const BankClearanceSummaryView = () => {
data={data.message.result} data={data.message.result}
columns={clearanceColumns} columns={clearanceColumns}
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`} getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
maxHeight="calc(100vh - 200px)" className="min-h-0 flex-1"
scrollAreaClassName="min-h-[calc(100vh-200px)]" maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={_("No rows to display.")} emptyState={_("No rows to display.")}
/> />
) : null} ) : null}

View File

@@ -74,7 +74,10 @@ const BankPicker = ({ className }: { className?: string }) => {
} }
return ( return (
<div <div
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4", // No trailing padding: it would sit inside the fade region, so the mask would
// spend itself on empty space and the last card would stop short of the balance
// panel instead of fading towards it. The column gap provides the separation.
className={cn("flex gap-3 items-stretch w-full overflow-x-auto scroll-fade-x",
banks?.length > 4 ? 'pb-2' : '', className, banks?.length > 4 ? 'pb-2' : '', className,
)} )}
style={{ style={{
@@ -108,12 +111,12 @@ const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
role="button" role="button"
title={`Select ${bank.account_name}`} title={`Select ${bank.account_name}`}
onClick={onSelect} onClick={onSelect}
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer', // `shrink-0`: this is a horizontally scrolling row, so cards keep their own width
// instead of being compressed to fit the container.
className={cn('w-60 shrink-0 rounded-md border border-outline-gray-1 p-2 overflow-hidden cursor-pointer transition-colors',
isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1' isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1'
)} )}
> >
<BankLogo bank={bank} className="mb-2" /> <BankLogo bank={bank} className="mb-2" />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View File

@@ -5,107 +5,179 @@ import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod }
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react' import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react'
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { parse } from "chrono-node" import { parse } from "chrono-node"
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@/components/ui/calendar'
import useFiscalYear from '@/hooks/useFiscalYear' import useFiscalYear from '@/hooks/useFiscalYear'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import _ from '@/lib/translate' import _ from '@/lib/translate'
import { useDirection } from '@/components/ui/direction' import { useDirection } from '@/components/ui/direction'
import useResetScrollOnSearch from '@/hooks/useResetScrollOnSearch'
const DATE_FORMAT = 'YYYY-MM-DD'
/** Current fiscal year plus this many previous ones, for quarter/year options. */
const PREVIOUS_FISCAL_YEARS = 2
type DateOption = {
/** Stable id - used as the cmdk value and the React key. */
key: string
label: string
translatedLabel: string
fromDate: string
toDate: string
format: string
/** Extra terms to match against, beyond the labels and dates. */
keywords?: string[]
/** Whether to show this option when the search box is empty. */
isDefault?: boolean
}
/**
* Fiscal years keep the same month/day boundaries year on year, so previous years can be
* derived by subtracting whole years instead of fetching them. Works for both Jan-Dec and
* Apr-Mar style fiscal years.
*/
const fiscalYearLabel = (start: dayjs.Dayjs, end: dayjs.Dayjs) =>
start.year() === end.year() ? `${start.year()}` : `${start.year()}-${end.year()}`
const BankRecDateFilter = () => { const BankRecDateFilter = () => {
const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom) const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom)
const { data: fiscalYear } = useFiscalYear() const { fiscalYear } = useFiscalYear()
const timePeriodOptions = useMemo(() => { const today = useMemo(() => dayjs().format(DATE_FORMAT), [])
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
const allOptions = useMemo(() => {
const standardOptions: DateOption[] = AVAILABLE_TIME_PERIODS.map((period) => {
const dates = getDatesForTimePeriod(period) const dates = getDatesForTimePeriod(period)
return { return {
key: period,
label: period, label: period,
translatedLabel: dates.translatedLabel ?? _(period),
fromDate: dates.fromDate, fromDate: dates.fromDate,
toDate: dates.toDate, toDate: dates.toDate,
format: dates.format, format: dates.format,
translatedLabel: dates.translatedLabel isDefault: true,
} }
}) })
if (fiscalYear?.message) { if (!fiscalYear) {
// For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters return standardOptions
const fiscalYearStart = fiscalYear.message.year_start_date
const fiscalYearEnd = fiscalYear.message.year_end_date
const q1 = {
label: `Q1: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`,
fromDate: fiscalYearStart,
toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q2 = {
label: `Q2: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q3 = {
label: `Q3: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q4 = {
label: `Q4: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
toDate: fiscalYearEnd,
format: 'MMM YYYY'
}
const thisYear = {
label: `This Fiscal Year`,
translatedLabel: `${_("This Fiscal Year")}`,
fromDate: fiscalYearStart,
toDate: fiscalYearEnd,
format: 'MMM YYYY'
}
const lastYear = {
label: `Last Fiscal Year`,
translatedLabel: `${_("Last Fiscal Year")}`,
fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
// Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options
const topRankedItems = standardOptions.filter((option) => {
return option.label === "This Month" || option.label === "Last Month"
})
const bottomRankedItems = standardOptions.filter((option) => {
return option.label !== "This Month" && option.label !== "Last Month"
})
return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems]
} }
return standardOptions const currentStart = dayjs(fiscalYear.year_start_date)
const currentEnd = dayjs(fiscalYear.year_end_date)
const quarterOptions: DateOption[] = []
const fiscalYearOptions: DateOption[] = []
// Static literals so the translation extractor can find them.
const quarterLabels = [_("Q1"), _("Q2"), _("Q3"), _("Q4")]
for (let yearsAgo = 0; yearsAgo <= PREVIOUS_FISCAL_YEARS; yearsAgo++) {
const start = currentStart.subtract(yearsAgo, 'year')
const end = currentEnd.subtract(yearsAgo, 'year')
// Keep the real name for the current year; derive it for the earlier ones.
const yearLabel = yearsAgo === 0 ? fiscalYear.name : fiscalYearLabel(start, end)
for (let quarter = 0; quarter < 4; quarter++) {
const quarterStart = start.add(quarter * 3, 'month')
// End the day before the next quarter starts, clamped to the fiscal year end
// so a short fiscal year can't spill over.
const nextQuarterStart = start.add((quarter + 1) * 3, 'month')
const quarterEnd = nextQuarterStart.subtract(1, 'day').isAfter(end)
? end
: nextQuarterStart.subtract(1, 'day')
if (quarterStart.isAfter(end)) continue
quarterOptions.push({
key: `Q${quarter + 1}-${yearLabel}`,
label: `Q${quarter + 1}: ${yearLabel}`,
translatedLabel: `${quarterLabels[quarter]}: ${yearLabel}`,
fromDate: quarterStart.format(DATE_FORMAT),
toDate: quarterEnd.format(DATE_FORMAT),
format: 'MMM YYYY',
keywords: ['quarter', `q${quarter + 1}`, yearLabel],
// Only the current fiscal year's quarters clutter the default list;
// older ones stay searchable.
isDefault: yearsAgo === 0,
})
}
const label = yearsAgo === 0
? 'This Fiscal Year'
: yearsAgo === 1
? 'Last Fiscal Year'
: `FY ${yearLabel}`
fiscalYearOptions.push({
key: `fiscal-year-${yearLabel}`,
label,
translatedLabel: yearsAgo <= 1 ? _(label) : `${_("FY")} ${yearLabel}`,
fromDate: start.format(DATE_FORMAT),
toDate: end.format(DATE_FORMAT),
format: 'MMM YYYY',
keywords: ['fiscal year', yearLabel],
isDefault: yearsAgo <= 1,
})
}
// "This Month"/"Last Month" first, then quarters and fiscal years, then the rest.
const topRanked = standardOptions.filter((o) => o.label === 'This Month' || o.label === 'Last Month')
const bottomRanked = standardOptions.filter((o) => o.label !== 'This Month' && o.label !== 'Last Month')
return [...topRanked, ...quarterOptions, ...fiscalYearOptions, ...bottomRanked]
}, [fiscalYear]) }, [fiscalYear])
// Reconciliation only looks backwards, so a period that hasn't started is never useful.
const selectableOptions = useMemo(
() => allOptions.filter((option) => option.fromDate <= today),
[allOptions, today],
)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [value, setValue] = useState("") const [value, setValue] = useState("")
// We filter ourselves (`shouldFilter={false}`) so that the parsed-date suggestion can be a
// real CommandItem alongside the predefined options, and keyboard navigation covers both.
const filteredOptions = useMemo(() => {
const query = value.trim().toLowerCase()
if (!query) {
return selectableOptions.filter((option) => option.isDefault)
}
const tokens = query.split(/\s+/)
return selectableOptions.filter((option) => {
const haystack = [
option.label,
option.translatedLabel,
...(option.keywords ?? []),
option.fromDate,
option.toDate,
].join(' ').toLowerCase()
return tokens.every((token) => haystack.includes(token))
})
}, [selectableOptions, value])
const parsedOption = useMemo(() => parseDateRange(value), [value])
// Filtering shortens the list, so pin the scroll back to the top to keep the
// auto-selected first option in view.
const listRef = useResetScrollOnSearch(value)
// Don't show a parsed suggestion that duplicates an option already in the list.
const showParsedOption = parsedOption
&& !filteredOptions.some((o) => o.fromDate === parsedOption.fromDate && o.toDate === parsedOption.toDate)
const timePeriod: TimePeriod | string = useMemo(() => { const timePeriod: TimePeriod | string = useMemo(() => {
if (bankRecDate.fromDate && bankRecDate.toDate) { if (bankRecDate.fromDate && bankRecDate.toDate) {
// Check if the from and to dates match any predefined time period for (const period of allOptions) {
for (const period of timePeriodOptions) {
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) { if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
return period.label; return period.label;
} }
@@ -114,10 +186,11 @@ const BankRecDateFilter = () => {
} else { } else {
return "Date Range"; return "Date Range";
} }
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]); }, [bankRecDate.fromDate, bankRecDate.toDate, allOptions]);
const handleTimePeriodChange = (fromDate: string, toDate: string) => { const handleTimePeriodChange = (fromDate: string, toDate: string) => {
setBankRecDate({ fromDate, toDate }) setBankRecDate({ fromDate, toDate })
setValue("")
setOpen(false) setOpen(false)
} }
@@ -130,7 +203,9 @@ const BankRecDateFilter = () => {
const direction = useDirection() const direction = useDirection()
const RangeArrow = direction === 'ltr'
? <ChevronRight className='text-[12px] text-ink-gray-5/70' />
: <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />
return <div className='flex items-center'> return <div className='flex items-center'>
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -141,30 +216,57 @@ const BankRecDateFilter = () => {
size='md' size='md'
className='rounded-e-none border-e-0' className='rounded-e-none border-e-0'
role="combobox"> role="combobox">
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)} {allOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
<ChevronDownIcon /> <ChevronDownIcon />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-84 p-1" align='start'> <PopoverContent className="w-84 p-1" align='start'>
<Command> <Command shouldFilter={false}>
<CommandInput placeholder="e.g. Last 3 weeks" onValueChange={setValue} value={value} /> <CommandInput placeholder={_("e.g. Last 3 weeks, Q1, May 2025")} onValueChange={setValue} value={value} />
<CommandList className='max-h-fit'> <CommandList ref={listRef} className='max-h-80'>
<CommandEmpty className='text-start p-2 hover:bg-surface-gray-1'> {showParsedOption && parsedOption && (
<EmptyState onSelect={handleTimePeriodChange} value={value} /> <CommandGroup heading={_("Matched date")}>
</CommandEmpty> <CommandItem
{timePeriodOptions.map((period) => ( value='parsed-date-range'
<CommandItem key={period.label} className='flex justify-between' onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}> className='flex justify-between'
<span> onSelect={() => handleTimePeriodChange(parsedOption.fromDate, parsedOption.toDate)}>
{period.translatedLabel ?? _(period.label)} <span className='max-w-[45%] truncate'>{value}</span>
</span> <span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'> {parsedOption.fromDate === parsedOption.toDate
{formatDate(period.fromDate, period.format)} {direction === 'ltr' ? <ChevronRight className='text-[12px] text-ink-gray-5/70' /> : <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />} {formatDate(period.toDate, period.format)} ? formatDate(parsedOption.fromDate, 'Do MMM YYYY')
</span> : <>{formatDate(parsedOption.fromDate, 'Do MMM YY')} {RangeArrow} {formatDate(parsedOption.toDate, 'Do MMM YY')}</>}
</CommandItem> </span>
))} </CommandItem>
</CommandGroup>
)}
{filteredOptions.length > 0 && (
<CommandGroup>
{filteredOptions.map((period) => (
<CommandItem
key={period.key}
value={period.key}
className='flex justify-between'
onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
<span>
{period.translatedLabel}
</span>
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
{formatDate(period.fromDate, period.format)} {RangeArrow} {formatDate(period.toDate, period.format)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
{!showParsedOption && filteredOptions.length === 0 && (
<div className='p-2 text-sm text-ink-gray-5'>
{_("No results found")}
</div>
)}
</CommandList> </CommandList>
</Command> </Command>
@@ -199,77 +301,97 @@ const BankRecDateFilter = () => {
} }
const referentialKeywords = ["last", "this", "next", "previous"] const referentialKeywords = ["last", "this", "next", "previous"]
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
const dates = useMemo(() => { /** chrono exposes `knownValues` on ParsingComponents but doesn't type it publicly. */
if (value) { const knownValuesOf = (components: unknown): Record<string, number> =>
// Try parsing the value (components as { knownValues?: Record<string, number> })?.knownValues ?? {}
const parsedDate = parse(value, undefined, { forwardDate: false })
if (parsedDate && parsedDate.length > 0) { /**
const startDate = parsedDate[0].start.date() * How far back a parsed date must move to land in the past. Reconciliation only ever looks
const endDate = parsedDate[0].end?.date() * backwards, so an ambiguous input that chrono resolves into the future - "December" typed in
* September, or a bare weekday like "Friday" - is pulled to its most recent past occurrence.
* An explicitly stated year is respected; a range that is still future gets discarded later.
*
* This returns a shift rather than a date so that a range can be moved as a single unit -
* shifting its start and end independently would distort or invert it.
*/
const pastShift = (date: Date, knownValues: Record<string, number>) => {
const today = dayjs()
let candidate = dayjs(date)
if (!endDate) { if (!candidate.isAfter(today, 'date') || knownValues.year !== undefined) {
const today = new Date() return { amount: 0, unit: 'year' as const }
// If today is greater than the start date, use today as the end date
if (startDate.getTime() > today.getTime()) {
return { fromDate: today, toDate: startDate }
} else {
// Check if the user only wants a specific month like "May 2025"
// If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) {
return {
fromDate: startDate,
toDate: dayjs(startDate).endOf('month').toDate()
}
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
} else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
// If month and day is known, then we should not assume that the user wants to get everything until today
return {
fromDate: startDate,
toDate: startDate,
}
}
return {
fromDate: startDate,
toDate: today
}
}
} else {
return { fromDate: startDate, toDate: endDate }
}
}
}
}, [value])
const onClick = (fromDate: Date, toDate: Date) => {
onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD'))
} }
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date') // A bare weekday repeats weekly, everything else (month/day) repeats yearly.
const unit = knownValues.weekday !== undefined && knownValues.day === undefined
? 'day' as const
: 'year' as const
const step = unit === 'day' ? 7 : 1
let amount = 0
return <div> for (let i = 0; i < 200 && candidate.isAfter(today, 'date'); i++) {
{dates ? candidate = candidate.subtract(step, unit)
<div className='flex gap-2 items-center justify-between cursor-pointer' onClick={() => onClick(dates.fromDate, dates.toDate)}> amount += step
<span className='text-sm text-ink-gray-5 max-w-[30%]'> }
{value}
</span> return { amount, unit }
{isEqual ? <span className='text-xs text-ink-gray-5 text-balance flex items-center gap-1'>
{formatDate(dates.fromDate, 'Do MMM YYYY')}
</span> :
<span className='text-xs text-ink-gray-5 flex items-center gap-1'>
{formatDate(dates.fromDate, 'Do MMM YY')} <ChevronRight size='16' className='text-ink-gray-5' /> {formatDate(dates.toDate, 'Do MMM YY')}
</span>}
</div> :
<span className='text-sm text-ink-gray-5'>
No results found
</span>
}
</div>
} }
export default BankRecDateFilter /**
* Parse free text into a past date range, or return undefined when it can't be parsed or
* resolves entirely into the future.
*/
const parseDateRange = (value: string): { fromDate: string, toDate: string } | undefined => {
if (!value.trim()) return undefined
const parsedDate = parse(value, undefined, { forwardDate: false })
if (!parsedDate || parsedDate.length === 0) return undefined
const result = parsedDate[0]
const startKnownValues = knownValuesOf(result.start)
// Anchor the shift on the start and apply it to both ends, so an explicit range like
// "1st Sept to 30th Sept" keeps its shape instead of having only its end rolled back.
const shift = pastShift(result.start.date(), startKnownValues)
const startDate = dayjs(result.start.date()).subtract(shift.amount, shift.unit).toDate()
const endDate = result.end
? dayjs(result.end.date()).subtract(shift.amount, shift.unit).toDate()
: undefined
const today = new Date()
let range: { fromDate: Date, toDate: Date }
if (endDate) {
const endKnownValues = knownValuesOf(result.end)
// chrono ends "Apr 2025 to Jun 2025" on the 1st of June, but the user means all of it.
const rangeEnd = endKnownValues.month && !endKnownValues.day
? dayjs(endDate).endOf('month').toDate()
: endDate
range = { fromDate: startDate, toDate: rangeEnd }
} else if (startKnownValues.month && !startKnownValues.day) {
// The user only wants a specific month like "May 2025" - span the whole month
range = { fromDate: dayjs(startDate).startOf('month').toDate(), toDate: dayjs(startDate).endOf('month').toDate() }
} else if (startKnownValues.month && startKnownValues.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
// If month and day is known, then we should not assume that the user wants to get everything until today
range = { fromDate: startDate, toDate: startDate }
} else {
range = { fromDate: startDate, toDate: today }
}
// A range that hasn't started yet is never useful for reconciliation. A range that merely
// ends in the future is kept as typed, the same way "This Month" spans the whole month.
if (dayjs(range.fromDate).isAfter(today, 'date')) return undefined
if (dayjs(range.toDate).isBefore(range.fromDate, 'date')) {
range = { fromDate: range.toDate, toDate: range.fromDate }
}
return {
fromDate: dayjs(range.fromDate).format(DATE_FORMAT),
toDate: dayjs(range.toDate).format(DATE_FORMAT),
}
}
export default BankRecDateFilter

View File

@@ -191,9 +191,9 @@ const BankReconciliationStatementView = () => {
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`]) const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
return <div className="space-y-4 py-2"> return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
<div> <div className="shrink-0">
<span className="text-p-sm"> <span className="text-p-sm">
<MarkdownRenderer content={content} /> <MarkdownRenderer content={content} />
</span> </span>
@@ -201,16 +201,18 @@ const BankReconciliationStatementView = () => {
{error && <ErrorBanner error={error} />} {error && <ErrorBanner error={error} />}
{data && <SummarySection data={data} />} {data && <div className="shrink-0"><SummarySection data={data} /></div>}
{data && data.message.result.length > 0 && ( {data && data.message.result.length > 0 && (
<div className="space-y-2"> <div className="flex min-h-0 flex-1 flex-col space-y-2">
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p> <p className="shrink-0 text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
<ListView <ListView
data={statementRows} data={statementRows}
columns={statementColumns} columns={statementColumns}
getRowId={(row) => row.payment_entry} getRowId={(row) => row.payment_entry}
maxHeight="min(70vh, 640px)" className="min-h-0 flex-1"
maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={_("No entries with a payment document in this list.")} emptyState={_("No entries with a payment document in this list.")}
/> />
</div> </div>

View File

@@ -245,9 +245,9 @@ const BankTransactionListView = () => {
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`]) const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-2 py-2"> return <div className="flex min-h-0 flex-1 flex-col space-y-2 py-2">
<div className="flex gap-2 justify-between items-center"> <div className="flex shrink-0 gap-2 justify-between items-center">
<span className="text-p-sm"> <span className="text-p-sm">
<MarkdownRenderer content={content} /> <MarkdownRenderer content={content} />
</span> </span>
@@ -278,8 +278,9 @@ const BankTransactionListView = () => {
data={filteredResults} data={filteredResults}
columns={transactionColumns} columns={transactionColumns}
getRowId={(row) => row.name} getRowId={(row) => row.name}
maxHeight="calc(100vh - 200px)" className="min-h-0 flex-1"
scrollAreaClassName="min-h-[calc(100vh-200px)]" maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={<Empty> emptyState={<Empty>
<EmptyMedia> <EmptyMedia>
<ListIcon /> <ListIcon />

View File

@@ -181,9 +181,9 @@ const IncorrectlyClearedEntriesView = () => {
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`]) const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2"> return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
<div> <div className="shrink-0">
<span className="text-p-sm"> <span className="text-p-sm">
<MarkdownRenderer content={content} /> <MarkdownRenderer content={content} />
<br /> <br />
@@ -198,13 +198,15 @@ const IncorrectlyClearedEntriesView = () => {
{error && <ErrorBanner error={error} />} {error && <ErrorBanner error={error} />}
{data && data.message.result.length > 0 && ( {data && data.message.result.length > 0 && (
<div className="space-y-2"> <div className="flex min-h-0 flex-1 flex-col space-y-2">
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p> <p className="shrink-0 text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
<ListView <ListView
data={data.message.result} data={data.message.result}
columns={incorrectlyClearedColumns} columns={incorrectlyClearedColumns}
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`} getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
maxHeight="min(70vh, 640px)" className="min-h-0 flex-1"
maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={_("No rows to display.")} emptyState={_("No rows to display.")}
/> />
</div> </div>

View File

@@ -37,7 +37,7 @@ import { Link } from "react-router"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group" import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => { const MatchAndReconcile = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom) const selectedBank = useAtomValue(selectedBankAccountAtom)
if (!selectedBank) { if (!selectedBank) {
@@ -52,15 +52,15 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
} }
return <> return <>
<div className={`flex items-start space-x-2`} > <div className="flex min-h-0 flex-1 items-stretch space-x-2" >
<div className="flex-1"> <div className="flex min-h-0 flex-1 flex-col">
<H4 className="text-sm font-medium">{_("Unreconciled Transactions")}</H4> <H4 className="shrink-0 text-sm font-medium">{_("Unreconciled Transactions")}</H4>
<UnreconciledTransactions contentHeight={contentHeight} /> <UnreconciledTransactions />
</div> </div>
<Separator orientation="vertical" style={{ minHeight: `${contentHeight}px` }} /> <Separator orientation="vertical" className="self-stretch" />
<div className="flex-1 px-1"> <div className="flex min-h-0 flex-1 flex-col px-1">
<H4 className="text-sm font-medium">{_("Match or Create")}</H4> <H4 className="shrink-0 text-sm font-medium">{_("Match or Create")}</H4>
<VouchersSection contentHeight={contentHeight} /> <VouchersSection />
</div> </div>
</div> </div>
<TransferModal /> <TransferModal />
@@ -69,16 +69,19 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
</> </>
} }
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */ /**
* TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets
* the real height. The scroll container fills its flex parent rather than taking a pixel
* height - the virtualizer observes its own rect, so it stays correct across resizes and any
* layout change above it.
*/
function VirtualizedListBody<T>({ function VirtualizedListBody<T>({
items, items,
height,
getItemKey, getItemKey,
children, children,
estimateSize = 74, estimateSize = 74,
}: { }: {
items: T[] items: T[]
height: number
getItemKey: (item: T, index: number) => string | number getItemKey: (item: T, index: number) => string | number
children: (item: T, index: number) => React.ReactNode children: (item: T, index: number) => React.ReactNode
estimateSize?: number estimateSize?: number
@@ -100,8 +103,7 @@ function VirtualizedListBody<T>({
return ( return (
<div <div
ref={scrollRef} ref={scrollRef}
className="overflow-auto contain-strict" className="min-h-0 flex-1 overflow-auto contain-strict"
style={{ height }}
> >
<div <div
className="relative w-full" className="relative w-full"
@@ -123,7 +125,7 @@ function VirtualizedListBody<T>({
) )
} }
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => { const UnreconciledTransactions = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom) const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '') const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
@@ -187,14 +189,13 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
} }
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0 const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
const listHeight = contentHeight - 72
if (isLoading) { if (isLoading) {
return <UnreconciledTransactionsLoadingState /> return <UnreconciledTransactionsLoadingState />
} }
return <div className="space-y-1"> return <div className="flex min-h-0 flex-1 flex-col space-y-1">
<div className="flex py-2 w-full gap-2"> <div className="flex py-2 w-full gap-2 shrink-0">
<InputGroup variant='outline'> <InputGroup variant='outline'>
<label className="sr-only">{_("Search transactions")}</label> <label className="sr-only">{_("Search transactions")}</label>
@@ -278,7 +279,6 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
<VirtualizedListBody <VirtualizedListBody
items={results} items={results}
height={listHeight}
estimateSize={74} estimateSize={74}
getItemKey={(transaction) => transaction.name} getItemKey={(transaction) => transaction.name}
> >
@@ -381,7 +381,7 @@ const UnreconciledTransactionItem = ({ transaction }: { transaction: Unreconcile
} }
const VouchersSection = ({ contentHeight }: { contentHeight: number }) => { const VouchersSection = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom) const selectedBank = useAtomValue(selectedBankAccountAtom)
const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || '')) const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
@@ -402,8 +402,8 @@ const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
return <OptionsForMultipleTransactions transactions={selectedTransactions} /> return <OptionsForMultipleTransactions transactions={selectedTransactions} />
} }
return <div style={{ minHeight: contentHeight }} className="mt-2"> return <div className="mt-2 flex min-h-0 flex-1 flex-col">
<OptionsForSingleTransaction transaction={selectedTransactions[0]} contentHeight={contentHeight} /> <OptionsForSingleTransaction transaction={selectedTransactions[0]} />
</div> </div>
} }
@@ -535,11 +535,11 @@ const OptionsForMultipleTransactions = ({ transactions }: { transactions: Unreco
} }
const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => { const OptionsForSingleTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts() const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
return <div className="flex flex-col gap-3"> return <div className="flex min-h-0 flex-1 flex-col gap-3">
<TooltipProvider> <TooltipProvider>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<div className="flex gap-4 justify-center"> <div className="flex gap-4 justify-center">
@@ -602,7 +602,7 @@ const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transacti
</div> </div>
</TooltipProvider> </TooltipProvider>
{transaction.matched_transaction_rule && <RuleAction transaction={transaction} />} {transaction.matched_transaction_rule && <RuleAction transaction={transaction} />}
<VouchersForTransaction transaction={transaction} contentHeight={contentHeight} /> <VouchersForTransaction transaction={transaction} />
</div> </div>
} }
@@ -774,12 +774,11 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
) )
} }
const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => { const VouchersForTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction) const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
const voucherList = vouchers?.message ?? [] const voucherList = vouchers?.message ?? []
const listHeight = contentHeight - 120
if (error) { if (error) {
return <ErrorBanner error={error} /> return <ErrorBanner error={error} />
@@ -801,8 +800,8 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
</div> </div>
} }
return <div className="relative space-y-2"> return <div className="relative flex min-h-0 flex-1 flex-col space-y-2">
<div className="flex items-center gap-2 text-sm text-ink-gray-5"> <div className="flex shrink-0 items-center gap-2 text-sm text-ink-gray-5">
<Separator className="flex-1" /> <Separator className="flex-1" />
<span>or</span> <span>or</span>
<Separator className="flex-1" /> <Separator className="flex-1" />
@@ -818,7 +817,6 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
</Empty>} </Empty>}
<VirtualizedListBody <VirtualizedListBody
items={voucherList} items={voucherList}
height={listHeight}
estimateSize={121} estimateSize={121}
getItemKey={(voucher) => voucher.name} getItemKey={(voucher) => voucher.name}
> >

View File

@@ -59,8 +59,8 @@ const SelectedTransactionDetails = ({ transaction, showAccount = false, account
</div> </div>
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<span className='text-sm'>{transaction.description}</span> <span className='text-p-sm'>{transaction.description}</span>
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null} {transaction.reference_number ? <span className='text-p-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
{showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null} {showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null}
</div> </div>

View File

@@ -490,7 +490,7 @@ const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transact
<Calendar size='16px' /> <Calendar size='16px' />
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span> <span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
</div> </div>
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span> <span className='text-p-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -83,10 +83,13 @@ const StatementDetails = ({ data }: Props) => {
} }
// `progress` is a percentage (drives the bar); `current`/`total` are actual counts.
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const [imported, setImported] = useState({ current: 0, total: 0 })
useFrappeEventListener("bank-rec-statement-import-progress", (event) => { useFrappeEventListener("bank-rec-statement-import-progress", (event) => {
setProgress(event.progress) setProgress(event.progress)
setImported({ current: event.current ?? 0, total: event.total ?? 0 })
}) })
const file_name = data.doc.file.split("/").pop() ?? "" const file_name = data.doc.file.split("/").pop() ?? ""
@@ -112,7 +115,9 @@ const StatementDetails = ({ data }: Props) => {
{data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> : {data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> :
<Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'> <Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'>
{loading ? <Loader2Icon className='size-4 animate-spin' /> : null} {loading ? <Loader2Icon className='size-4 animate-spin' /> : null}
{loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button> {loading ? _("Importing...") : data.final_transactions?.length === 1
? _("Import 1 transaction")
: _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
} }
</div> </div>
<div className='flex items-start gap-4'> <div className='flex items-start gap-4'>
@@ -129,7 +134,9 @@ const StatementDetails = ({ data }: Props) => {
</div> </div>
{progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" /> {progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" />
<span className='text-sm'>{_("Importing {0} transactions", [progress.toString()])} <span className='text-sm'>{imported.total === 1
? _("Importing 1 transaction")
: _("Importing {0} of {1} transactions", [imported.current.toString(), imported.total.toString()])}
</span> </span>
</div>} </div>}

View File

@@ -387,7 +387,7 @@ function ListViewInner<TData>({
)} )}
role="columnheader" role="columnheader"
> >
<div className="min-w-0 flex-1 truncate"> <div className="min-w-0 flex-1 truncate leading-snug">
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender(header.column.columnDef.header, header.getContext())} : flexRender(header.column.columnDef.header, header.getContext())}

View File

@@ -1,13 +1,58 @@
import { useFrappeGetCall } from "frappe-react-sdk" import { useFrappeGetCall } from "frappe-react-sdk"
import { useMemo } from "react"
import dayjs from "dayjs"
import { useCurrentCompany } from "./useCurrentCompany"
const useFiscalYear = () => { export type FiscalYear = {
name: string
return useFrappeGetCall("erpnext.accounts.utils.get_fiscal_year", undefined, 'fiscal_year', { year_start_date: string
revalidateOnFocus: false, year_end_date: string
revalidateIfStale: false,
revalidateOnReconnect: false
})
} }
export default useFiscalYear /**
* The fiscal year containing today, for the currently selected company.
*
* `company` matters in multi-company setups, where fiscal years can be restricted to
* specific companies. `date` matters because without it `get_fiscal_year` returns the newest
* fiscal year in the system (they're ordered by start date, descending) - which may be one
* created in advance for a year that hasn't started.
*/
const useFiscalYear = () => {
const company = useCurrentCompany()
const { data, ...rest } = useFrappeGetCall<{ message: FiscalYear | [string, string, string] | false }>(
"erpnext.accounts.utils.get_fiscal_year",
{
date: dayjs().format("YYYY-MM-DD"),
company,
as_dict: 1,
// Return nothing instead of throwing/msgprinting when no fiscal year covers today.
raise_on_missing: 0,
verbose: 0,
},
company ? `fiscal_year_${company}` : null,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false
}
)
// get_fiscal_year returns a dict with as_dict, a (name, start, end) tuple without it, and
// false when there's no match - normalise all three.
const fiscalYear = useMemo<FiscalYear | undefined>(() => {
const message = data?.message
if (!message) return undefined
if (Array.isArray(message)) {
const [name, year_start_date, year_end_date] = message
return { name, year_start_date, year_end_date }
}
return message
}, [data])
return { fiscalYear, ...rest }
}
export default useFiscalYear

View File

@@ -0,0 +1,23 @@
import { useLayoutEffect, useRef } from "react"
/**
* Pins a scrollable list back to the top whenever the search term changes.
*
* Dropdowns that do their own filtering (`shouldFilter={false}`) swap a long list for a much
* shorter one while the scroll container keeps its previous offset - which can leave the
* auto-selected first item scrolled out of view.
*
* Returns a ref to attach to the scroll container (e.g. `CommandList`).
*/
const useResetScrollOnSearch = (search: string) => {
const listRef = useRef<HTMLDivElement>(null)
// Layout effect so the reset lands before paint, avoiding a visible jump.
useLayoutEffect(() => {
listRef.current?.scrollTo({ top: 0 })
}, [search])
return listRef
}
export default useResetScrollOnSearch

View File

@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "./styles/scroll-fade.css";
@font-face { @font-face {
font-family: InterVariable; font-family: InterVariable;

View File

@@ -1,4 +1,4 @@
import BankBalance from "@/components/features/BankReconciliation/BankBalance" import BankAccountBalancePanel from "@/components/features/BankReconciliation/BankBalance"
import BankPicker from "@/components/features/BankReconciliation/BankPicker" import BankPicker from "@/components/features/BankReconciliation/BankPicker"
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter" import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal" import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
@@ -9,10 +9,9 @@ import ActionLog from "@/components/features/ActionLog/ActionLog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TooltipProvider } from "@/components/ui/tooltip" import { TooltipProvider } from "@/components/ui/tooltip"
import _ from "@/lib/translate" import _ from "@/lib/translate"
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react" import { lazy, Suspense } from "react"
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react" import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb" 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 { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useAtomValue } from "jotai" import { useAtomValue } from "jotai"
@@ -25,23 +24,13 @@ const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankR
const BankReconciliation = () => { 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 - 220
return ( return (
<div> <div>
<div className="p-4 flex-col gap-4 md:flex hidden"> {/* The page owns the viewport height and the tabs/lists below fill what's left, so
<div ref={ref} className="flex flex-col gap-4"> the virtualizers size themselves from layout instead of a measured pixel value. */}
<div className="flex justify-between"> <div className="px-2 pt-1 flex-col gap-4 md:flex hidden h-dvh">
<div className="flex flex-col gap-4 shrink-0">
<div className="flex justify-between shrink-0">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
@@ -54,7 +43,7 @@ const BankReconciliation = () => {
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbPage> <BreadcrumbPage>
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
{_("Banking")} <Badge theme="violet" variant="subtle">{_("Beta")}</Badge> {_("Banking")}
</div> </div>
</BreadcrumbPage> </BreadcrumbPage>
@@ -71,10 +60,8 @@ const BankReconciliation = () => {
<BankRecDateFilter /> <BankRecDateFilter />
</div> </div>
</div> </div>
<BankPicker />
<BankBalance />
</div> </div>
<BankRecTabs remainingHeightAfterTabs={remainingHeightAfterTabs} /> <BankRecWorkspace />
<BankTransactionUnreconcileModal /> <BankTransactionUnreconcileModal />
</div> </div>
<div className="md:hidden flex h-screen items-center justify-between"> <div className="md:hidden flex h-screen items-center justify-between">
@@ -104,42 +91,53 @@ const BankReconciliation = () => {
) )
} }
const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: number }) => { const BankRecWorkspace = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom) const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
if (!selectedBankAccount) { return <Tabs defaultValue="Match and Reconcile" className="min-h-0 flex-1 gap-4">
return null {/* Picker + tab strip stack on the left, balance panel beside them - the tab strip
} fills height the panel needs anyway, so it costs no row of its own. The picker
scrolls horizontally (`min-w-0` lets it shrink so its overflow-x engages) while
return <Tabs defaultValue="Match and Reconcile"> the panel stays put, so the figures never scroll away. */}
<TabsList> {/* No gap here: the panel's own `border-s ps-4` supplies the separation, and a gap
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger> would leave dead space the picker's edge fade can't reach. */}
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Bank Reconciliation Statement")}</TabsTrigger> <div className="flex shrink-0 items-stretch">
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Bank Transactions")}</TabsTrigger> <div className="flex min-w-0 flex-1 flex-col justify-between gap-3">
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Bank Clearance Summary")}</TabsTrigger> <BankPicker />
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared Entries")}</TabsTrigger> {selectedBankAccount && <TabsList>
</TabsList> <TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
<TabsContent value="Match and Reconcile"> <TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Reconciliation Statement")}</TabsTrigger>
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} /> <TabsTrigger value="Bank Transactions"><ListIcon />{_("Transactions")}</TabsTrigger>
</TabsContent> <TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Clearance Summary")}</TabsTrigger>
<Suspense fallback={ <TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared")}</TabsTrigger>
<div className="flex items-center justify-center p-16"> </TabsList>}
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div> </div>
}> {selectedBankAccount && <BankAccountBalancePanel />}
<TabsContent value="Bank Reconciliation Statement"> </div>
<BankReconciliationStatement />
{selectedBankAccount && <>
<TabsContent value="Match and Reconcile" className="flex min-h-0 flex-col">
<MatchAndReconcile />
</TabsContent> </TabsContent>
<TabsContent value="Bank Transactions"> <Suspense fallback={
<BankTransactions /> <div className="flex items-center justify-center p-16">
</TabsContent> <Loader2Icon className="size-6 animate-spin text-muted-foreground" />
<TabsContent value="Bank Clearance Summary"> </div>
<BankClearanceSummary /> }>
</TabsContent> <TabsContent value="Bank Reconciliation Statement" className="flex min-h-0 flex-col">
<TabsContent value="Incorrectly Cleared Entries"> <BankReconciliationStatement />
<IncorrectlyClearedEntries /> </TabsContent>
</TabsContent> <TabsContent value="Bank Transactions" className="flex min-h-0 flex-col">
</Suspense> <BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary" className="flex min-h-0 flex-col">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries" className="flex min-h-0 flex-col">
<IncorrectlyClearedEntries />
</TabsContent>
</Suspense>
</>}
</Tabs> </Tabs>
} }

View File

@@ -226,7 +226,7 @@ const StatementImportLog = () => {
field: "creation", field: "creation",
order: "desc" order: "desc"
}, },
limit: 10 limit: 20
}, bankAccount ? undefined : null, { }, bankAccount ? undefined : null, {
revalidateOnFocus: false revalidateOnFocus: false
}) })

View File

@@ -0,0 +1,94 @@
/* Scroll-edge fade mask for horizontal scroll containers (the bank picker strip).
Ported from Raven's `scroll-fade-x`; imported by index.css, since Tailwind processes
`@utility` in imported files the same as in the entry file.
The scroll-timeline keyframes reveal each edge's fade only when there IS content to scroll
in that direction - no fade on the left edge when scrolled fully left, none on the right at
the end. `@property` makes the fade animate smoothly rather than jumping.
Without scroll-timeline support (Firefox) there is deliberately NO fade at all: the fade
vars stay at their 0px initial value and the gradient stops collapse to the edges. A static
both-edges fallback was tried in Raven and removed - on a container with nothing to scroll
it dimmed the edges anyway, promising content that didn't exist. */
@property --scroll-fade-l {
/* length-percentage, NOT length: the fade size is min(12%, …) - a percentage. A <length>
property rejects that value and reverts to initial-value (0px), zeroing the fade. */
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@property --scroll-fade-r {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@keyframes scroll-fade-reveal-l {
from {
--scroll-fade-l: 0px;
}
to {
--scroll-fade-l: var(--_scroll-fade-size-l);
}
}
@keyframes scroll-fade-reveal-r {
from {
--scroll-fade-r: var(--_scroll-fade-size-r);
}
to {
--scroll-fade-r: 0px;
}
}
@utility scroll-fade-x {
--_scroll-fade-size-l: var(--scroll-fade-l-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
--_scroll-fade-size-r: var(--scroll-fade-r-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
/* Eased (smoothstep) alpha ramp, sampled finely so it reads as a smooth curve, NOT fading
all the way to transparent: the edge floors at 0.25 (content dims, never vanishes), ramping
up to a full 1 for the body. The opaque end MUST be 1 or everything would be permanently
dimmed. Stops collapse to the edge when the size animates to 0, so the true first/last card
is never dimmed at rest. Tune the floor - higher (~0.4) = subtler, lower (~0.1) = stronger. */
--scroll-fade-inline: linear-gradient(to right,
rgba(0, 0, 0, 0.25) 0,
rgba(0, 0, 0, 0.282) calc(var(--scroll-fade-l, 0px) * 0.125),
rgba(0, 0, 0, 0.367) calc(var(--scroll-fade-l, 0px) * 0.25),
rgba(0, 0, 0, 0.487) calc(var(--scroll-fade-l, 0px) * 0.375),
rgba(0, 0, 0, 0.625) calc(var(--scroll-fade-l, 0px) * 0.5),
rgba(0, 0, 0, 0.763) calc(var(--scroll-fade-l, 0px) * 0.625),
rgba(0, 0, 0, 0.883) calc(var(--scroll-fade-l, 0px) * 0.75),
rgba(0, 0, 0, 0.968) calc(var(--scroll-fade-l, 0px) * 0.875),
rgba(0, 0, 0, 1) var(--scroll-fade-l, 0px),
rgba(0, 0, 0, 1) calc(100% - var(--scroll-fade-r, 0px)),
rgba(0, 0, 0, 0.968) calc(100% - var(--scroll-fade-r, 0px) * 0.875),
rgba(0, 0, 0, 0.883) calc(100% - var(--scroll-fade-r, 0px) * 0.75),
rgba(0, 0, 0, 0.763) calc(100% - var(--scroll-fade-r, 0px) * 0.625),
rgba(0, 0, 0, 0.625) calc(100% - var(--scroll-fade-r, 0px) * 0.5),
rgba(0, 0, 0, 0.487) calc(100% - var(--scroll-fade-r, 0px) * 0.375),
rgba(0, 0, 0, 0.367) calc(100% - var(--scroll-fade-r, 0px) * 0.25),
rgba(0, 0, 0, 0.282) calc(100% - var(--scroll-fade-r, 0px) * 0.125),
rgba(0, 0, 0, 0.25) 100%);
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation:
scroll-fade-reveal-l 1ms ease-in-out,
scroll-fade-reveal-r 1ms ease-in-out;
animation-timeline: scroll(self x), scroll(self x);
animation-range:
0 var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24)),
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24))) 100%;
animation-fill-mode: both;
}
}

View File

@@ -375,8 +375,7 @@ class BankStatementImportLog(Document):
table["column_mapping"] = guess_column_mapping_by_content(table["rows"]) table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
final_transactions, table["date_format"], table["amount_format"] = build_table_transactions(table) final_transactions, table["date_format"], table["amount_format"] = build_table_transactions(table)
# Tables with no detectable transactions (ads, summaries, headers) start excluded. table["included"] = should_include_table(table, final_transactions)
table["included"] = bool(final_transactions)
self.pdf_tables = json.dumps(tables) self.pdf_tables = json.dumps(tables)
return tables return tables
@@ -542,6 +541,8 @@ class BankStatementImportLog(Document):
"bank-rec-statement-import-progress", "bank-rec-statement-import-progress",
{ {
"progress": round(progress / total_transactions * 100), "progress": round(progress / total_transactions * 100),
"current": progress,
"total": total_transactions,
}, },
doctype="Bank Statement Import Log", doctype="Bank Statement Import Log",
docname=self.name, docname=self.name,
@@ -551,6 +552,7 @@ class BankStatementImportLog(Document):
"bank-rec-statement-import-progress", "bank-rec-statement-import-progress",
{ {
"progress": 100, "progress": 100,
"current": total_transactions,
"total": total_transactions, "total": total_transactions,
}, },
doctype="Bank Statement Import Log", doctype="Bank Statement Import Log",
@@ -821,6 +823,15 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
"""Pure version of the final-transaction builder (date normalized, amount split).""" """Pure version of the final-transaction builder (date normalized, amount split)."""
final_transactions = [] final_transactions = []
# Which marker does this statement actually write? A statement that only ever says "Cr"
# is marking the credits as its exceptions, so an unmarked row is a withdrawal; one that
# only ever says "Dr" means the opposite. With both markers present an unmarked row is
# genuinely undetermined, so it stays a withdrawal.
unmarked_is_deposit = False
if amount_format == 'Amount column has "CR"/"DR" values':
markers = {get_amount_cr_dr_marker(row.get("amount")) for row in transaction_rows}
unmarked_is_deposit = markers - {None} == {"dr"}
def parse_amount(transaction_row: dict): def parse_amount(transaction_row: dict):
if amount_format == "Separate columns for withdrawal and deposit": if amount_format == "Separate columns for withdrawal and deposit":
return get_float_amount(transaction_row.get("withdrawal")), get_float_amount( return get_float_amount(transaction_row.get("withdrawal")), get_float_amount(
@@ -829,44 +840,43 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
if amount_format == 'Amount column has "CR"/"DR" values': if amount_format == 'Amount column has "CR"/"DR" values':
amount = transaction_row.get("amount") amount = transaction_row.get("amount")
marker = get_amount_cr_dr_marker(amount)
# The marker carries the direction, so the amount's own sign is ignored.
signed_amount = get_float_amount(amount) or 0
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount if marker:
float_amount = abs(get_float_amount(amount) or 0) return (0, abs(signed_amount)) if marker == "cr" else (abs(signed_amount), 0)
if "cr" in amount.lower():
return 0, float_amount
else:
return float_amount, 0
# An unmarked row takes the opposite direction to the marker this statement
# uses. A negative amount reverses that again (a refund).
is_deposit = unmarked_is_deposit
if signed_amount < 0:
is_deposit = not is_deposit
return (0, abs(signed_amount)) if is_deposit else (abs(signed_amount), 0)
# `or 0` below: get_float_amount returns None for an unparseable cell, and a blank
# transaction-type cell comes through as None. Both used to raise.
if amount_format == "Amount column has positive/negative values": if amount_format == "Amount column has positive/negative values":
amount = get_float_amount(transaction_row.get("amount", "0")) amount = get_float_amount(transaction_row.get("amount", "0")) or 0
if amount > 0: if amount > 0:
return 0, abs(amount) return 0, abs(amount)
else: else:
return abs(amount), 0 return abs(amount), 0
transaction_type = str(transaction_row.get("debit_credit") or "").strip().lower()
amount = abs(get_float_amount(transaction_row.get("amount", "0")) or 0)
if amount_format == 'Transaction type column has "CR"/"DR" values': if amount_format == 'Transaction type column has "CR"/"DR" values':
transaction_type = transaction_row.get("debit_credit") # "credit" contains "cr". "debit" does not contain "dr", so it correctly falls
amount = get_float_amount(transaction_row.get("amount", "0")) # through to the withdrawal side.
if "cr" in transaction_type.lower(): return (0, amount) if "cr" in transaction_type else (amount, 0)
return 0, abs(amount)
else:
return abs(amount), 0
if amount_format == 'Transaction type column has "C"/"D" values': if amount_format == 'Transaction type column has "C"/"D" values':
transaction_type = transaction_row.get("debit_credit") return (0, amount) if transaction_type == "c" else (amount, 0)
amount = get_float_amount(transaction_row.get("amount", "0"))
if transaction_type.lower().strip() == "c":
return 0, abs(amount)
else:
return abs(amount), 0
if amount_format == 'Transaction type column has "Deposit"/"Withdrawal" values': if amount_format == 'Transaction type column has "Deposit"/"Withdrawal" values':
transaction_type = transaction_row.get("debit_credit") return (0, amount) if "deposit" in transaction_type else (amount, 0)
amount = get_float_amount(transaction_row.get("amount", "0"))
if "deposit" in transaction_type.lower():
return 0, abs(amount)
else:
return abs(amount), 0
return 0, 0 return 0, 0
@@ -910,6 +920,26 @@ def build_table_transactions(table: dict):
return final_transactions, date_format, amount_format return final_transactions, date_format, amount_format
def should_include_table(table: dict, final_transactions: list) -> bool:
"""
Whether a freshly extracted PDF table should START as included - only the default state
of the checkbox, which the user can change afterwards.
It must have yielded transactions, and it must have a Description column mapped. A
transaction table always carries a narration; the summary boxes printed around it -
payment due, credit limit, reward points - are dates and figures only. Otherwise the
HDFC credit-card "Payment Due Date / Total Dues / Minimum Amount Due" box parses as one
transaction and imports a phantom row.
A description is NOT needed to import (it is not mandatory on Bank Transaction), so a
bank that omits narration still works - its table just starts unticked.
"""
if not final_transactions:
return False
return any(column.get("maps_to") == "Description" for column in table.get("column_mapping", []))
def _clean_cell(cell) -> str: def _clean_cell(cell) -> str:
"""Normalize a pdfplumber cell: None -> '', collapse wrapped newlines, strip.""" """Normalize a pdfplumber cell: None -> '', collapse wrapped newlines, strip."""
if cell is None: if cell is None:
@@ -1055,6 +1085,43 @@ def get_float_amount(amount):
return amount return amount
# A "CR"/"DR" marker on the amount itself, at either end: "2,378.00Cr", "Cr 100",
# "INR 50.90 Cr.", "DR 1,234.50".
# `(?![a-zA-Z])` rather than `\b` on the leading form: there is no word boundary between
# the "r" of "Cr100" and the digit, but there IS one inside "CREDIT" and "DRAFT".
AMOUNT_CR_DR_PATTERN = re.compile(r"^\s*(cr|dr)(?![a-zA-Z])\.?|(?:^|[\s\d.)])(cr|dr)\b\.?\s*$", re.IGNORECASE)
def get_amount_cr_dr_marker(amount) -> str | None:
"""
Return "cr" or "dr" if the amount cell carries a direction marker of its own, else None.
What is left after removing the marker has to look like an amount - it must hold a digit
and at most a short currency token - so that text which merely starts or ends with the
letters is not read as a marker. That guard is what separates "Cr 100" from a
description that bled into the amount column, like "Dr Smith Clinic 500".
"""
if not isinstance(amount, str):
return None
match = AMOUNT_CR_DR_PATTERN.search(amount)
if not match:
return None
# Only the marker itself is removed - the surrounding character the pattern needed to
# anchor on (a digit, say) stays part of the remainder.
group = 1 if match.group(1) else 2
start, end = match.span(group)
remainder = amount[:start] + amount[end:]
if not any(char.isdigit() for char in remainder):
return None
if sum(char.isalpha() for char in remainder) > 3:
return None
return match.group(group).lower()
def get_file_properties(transactions: list): def get_file_properties(transactions: list):
""" """
From the transaction rows, try to figure out the following: From the transaction rows, try to figure out the following:
@@ -1075,6 +1142,8 @@ def get_file_properties(transactions: list):
'Transaction type column has "C"/"D" values': 0, 'Transaction type column has "C"/"D" values': 0,
} }
amount_column_has_cr_dr = False
for transaction in transactions: for transaction in transactions:
date_format = transaction.get("date_format") date_format = transaction.get("date_format")
@@ -1092,33 +1161,40 @@ def get_file_properties(transactions: list):
if not amount: if not amount:
continue continue
if isinstance(amount, str) and ("cr" in amount.lower() or "dr" in amount.lower()): debit_credit = str(transaction.get("debit_credit") or "").strip().lower()
# One vote per row, most specific signal first. Order matters: "withdrawal" contains
# "dr", so it must be matched before the loose cr/dr check or a Deposit/Withdrawal
# column reads as CR/DR. "debit" needs listing because, unlike "credit", it does not
# contain "dr". The final else means every row votes, even an unrecognised type.
if get_amount_cr_dr_marker(amount):
amount_column_has_cr_dr = True
amount_format_frequency['Amount column has "CR"/"DR" values'] += 1 amount_format_frequency['Amount column has "CR"/"DR" values'] += 1
elif "deposit" in debit_credit or "withdrawal" in debit_credit:
# Check if there's a debit_credit column containing "cr"/"dr" amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
if transaction.get("debit_credit", None): elif debit_credit in ("c", "d"):
if ( amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
"cr" in transaction.get("debit_credit", "").lower() elif any(token in debit_credit for token in ("cr", "dr", "debit")):
or "dr" in transaction.get("debit_credit", "").lower() amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
):
amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
elif (
"deposit" in transaction.get("debit_credit", "").lower()
or "withdrawal" in transaction.get("debit_credit", "").lower()
):
amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
elif (transaction.get("debit_credit", "").lower().strip() == "c") or (
transaction.get("debit_credit", "").lower().strip() == "d"
):
amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
# Else assume that the amount is expressed as positive/negative value
else: else:
# Nothing said which direction this is, so assume the amount carries the sign.
amount_format_frequency["Amount column has positive/negative values"] += 1 amount_format_frequency["Amount column has positive/negative values"] += 1
most_common_date_format = max(date_format_frequency, key=date_format_frequency.get) most_common_date_format = max(date_format_frequency, key=date_format_frequency.get)
most_common_amount_format = max(amount_format_frequency, key=amount_format_frequency.get) most_common_amount_format = max(amount_format_frequency, key=amount_format_frequency.get)
# With no votes at all (no rows, or every amount blank) max() would return whichever key
# happens to be first in the dict. Say what we mean instead.
if not amount_format_frequency[most_common_amount_format]:
most_common_amount_format = "Amount column has positive/negative values"
# A CR/DR amount column is proved by a single marker, not by a majority: both formats
# describe the same column, and an unmarked row is only the default direction, not
# evidence against the notation. Statements mark just the exceptions - one HDFC
# credit-card page has 18 rows and a single "50.90Cr".
if amount_column_has_cr_dr and most_common_amount_format == "Amount column has positive/negative values":
most_common_amount_format = 'Amount column has "CR"/"DR" values'
return most_common_date_format, most_common_amount_format return most_common_date_format, most_common_amount_format

View File

@@ -11,12 +11,14 @@ from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_lo
detect_column_mapping, detect_column_mapping,
detect_header_row, detect_header_row,
extract_pdf_tables, extract_pdf_tables,
get_amount_cr_dr_marker,
get_float_amount, get_float_amount,
get_statement_details, get_statement_details,
guess_column_mapping_by_content, guess_column_mapping_by_content,
reextract_pdf_table, reextract_pdf_table,
set_header_index, set_header_index,
set_pdf_table_header, set_pdf_table_header,
should_include_table,
update_column_mapping, update_column_mapping,
update_pdf_tables, update_pdf_tables,
) )
@@ -124,6 +126,184 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
self.assertIsNone(get_float_amount("ABCD")) self.assertIsNone(get_float_amount("ABCD"))
self.assertIsNone(get_float_amount("****")) self.assertIsNone(get_float_amount("****"))
# ------------------------------------------------------------------ #
# Amount format detection
# ------------------------------------------------------------------ #
def test_amount_cr_dr_marker(self):
"""The marker is read at either end of the cell, but only next to the amount."""
for amount in ("2,378.00Cr", "50.90 CR", "INR 50.90 Cr.", "1000cr", "5cr", "(100) Cr"):
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
for amount in ("2,378.00Dr", "50.90 DR", "1000dr", "-100 Dr"):
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
# Some banks put the marker in front of the digits instead.
for amount in ("Cr 100", "Cr100", "CR INR 100", "cr 0.00"):
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
for amount in ("Dr 100", "Dr100", "Dr. 1,234.50"):
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
for amount in ("100.00", "-2,000.00", "INR 25,236.00", "", None, 100.0):
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
# Text that merely starts or ends with the letters must not be read as a marker, or
# a description that bled into the amount column would reclassify the statement.
for amount in (
"CREDIT CARD PAYMENT 500",
"DRAFT 100",
"Dr Smith Clinic 500",
"DR AMBEDKAR ROAD BRANCH 500",
"500 CRC",
"Cheque Dr",
"Cr",
):
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
def test_sparsely_marked_cr_dr_amount_column(self):
"""One marker is enough to prove a CR/DR amount column - it is not a majority vote.
A real HDFC credit-card page carries 18 rows and a single "50.90Cr": the unmarked
rows are ordinary purchases, and only the exceptions are marked. A frequency vote
therefore picked "positive/negative" 17-1 and imported that lone credit as a debit.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Transaction Description", "Amount (in Rs.)"],
["21/07/2026", "ITC MAURYA NEW DELHI", "2,495.00"],
["22/07/2026", "ZOMATO LIMITED Gurugram", "1,288.68"],
["23/07/2026", "SWIGGY Bangalore", "532.00"],
["26/07/2026", "SWIGGY Bangalore", "1,043.00"],
["27/07/2026", "PETRO SURCHARGE WAIVER", "50.90Cr"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
# Only "Cr" appears, so it is the marked exception and unmarked rows are debits.
self.assertEqual(doc.total_credits, 50.90)
self.assertEqual(doc.total_credit_transactions, 1)
self.assertEqual(doc.total_debits, 5358.68)
self.assertEqual(doc.total_debit_transactions, 4)
def test_dr_only_statement_treats_unmarked_rows_as_deposits(self):
"""The mirror image of a Cr-only statement: only withdrawals are marked.
The unmarked default cannot be hardcoded to the debit, because which side gets
marked varies by bank. It is derived from the markers the statement actually uses -
here only "Dr" appears, so "Dr" is the exception and everything unmarked is a
deposit.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Amount"],
["01/04/2026", "ATM WITHDRAWAL", "2,000.00Dr"],
["03/04/2026", "SALARY", "20,000.00"],
["05/04/2026", "INTEREST", "150.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
self.assertEqual(doc.total_debits, 2000.0)
self.assertEqual(doc.total_debit_transactions, 1)
self.assertEqual(doc.total_credits, 20150.0)
self.assertEqual(doc.total_credit_transactions, 2)
def test_leading_cr_dr_markers(self):
"""Some banks print the marker in front of the amount."""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Amount"],
["01/04/2026", "ATM WITHDRAWAL", "Dr 2,000.00"],
["03/04/2026", "SALARY", "Cr 20,000.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
self.assertEqual(doc.total_debits, 2000.0)
self.assertEqual(doc.total_credits, 20000.0)
def test_partially_marked_cr_dr_amount_column(self):
"""A CR/DR amount column stays CR/DR even when some rows carry no marker.
Every unmarked row used to also vote for "positive/negative", so an ordinary
statement with a few unmarked rows was detected as positive/negative and a
"2000.00Dr" was then imported as a deposit.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Amount", "Balance"],
["01/04/2026", "OPENING FEE", "100.00", "9,900.00"],
["03/04/2026", "SALARY", "20000.00Cr", "29,900.00"],
["05/04/2026", "ATM WDL", "2000.00Dr", "27,900.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
# Both markers appear, so an unmarked row is undetermined and stays a debit.
self.assertEqual(doc.total_debits, 2100.0)
self.assertEqual(doc.total_debit_transactions, 2)
self.assertEqual(doc.total_credits, 20000.0)
self.assertEqual(doc.total_credit_transactions, 1)
def test_deposit_withdrawal_type_column(self):
"""The word Withdrawal contains "dr", so a loose CR/DR check claims this column first.
It then reads "Deposit" (which has no "cr" in it) as a withdrawal, flipping the
direction of every credit in the statement.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Transaction Type", "Amount"],
["01/04/2026", "ATM WDL", "Withdrawal", "2,000.00"],
["03/04/2026", "SALARY", "Deposit", "20,000.00"],
["05/04/2026", "ATM WDL", "Withdrawal", "500.00"],
]
)
self.assertEqual(
doc.detected_amount_format, 'Transaction type column has "Deposit"/"Withdrawal" values'
)
self.assertEqual(doc.total_debits, 2500.0)
self.assertEqual(doc.total_debit_transactions, 2)
self.assertEqual(doc.total_credits, 20000.0)
self.assertEqual(doc.total_credit_transactions, 1)
def test_unrecognised_type_column_falls_back_to_signed_amount(self):
"""An unrecognised transaction type must not stop the amount being read.
No tally was incremented for these rows, so max() returned the first key -
"Separate columns for withdrawal and deposit" - and, with no such columns in the
file, every amount came through as None.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Transaction Type", "Amount"],
["01/04/2026", "ATM WDL", "NEFT", "-2,000.00"],
["03/04/2026", "SALARY", "IMPS", "20,000.00"],
]
)
self.assertEqual(doc.detected_amount_format, "Amount column has positive/negative values")
self.assertEqual(doc.total_debits, 2000.0)
self.assertEqual(doc.total_credits, 20000.0)
def test_blank_transaction_type_cell(self):
"""A blank type cell used to raise - `None.lower()` - instead of parsing the row."""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Transaction Type", "Amount"],
["01/04/2026", "ATM WDL", "Dr", "2,000.00"],
["03/04/2026", "SALARY", "Cr", "20,000.00"],
["05/04/2026", "UNKNOWN", None, "500.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Transaction type column has "CR"/"DR" values')
# The unmarked row has no direction of its own, so it counts as a withdrawal.
self.assertEqual(doc.total_debits, 2500.0)
self.assertEqual(doc.total_credits, 20000.0)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# PDF statement import # PDF statement import
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -159,7 +339,8 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
else: else:
table["header_index"] = None table["header_index"] = None
table["column_mapping"] = guess_column_mapping_by_content(table["rows"]) table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
table["included"] = True final_transactions, _df, _af = build_table_transactions(table)
table["included"] = should_include_table(table, final_transactions)
return table return table
def test_pdf_multi_page_kept_separate_and_unioned(self): def test_pdf_multi_page_kept_separate_and_unioned(self):
@@ -197,6 +378,74 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
final, _df, _af = build_table_transactions(ad_table) final, _df, _af = build_table_transactions(ad_table)
self.assertEqual(final, []) self.assertEqual(final, [])
def test_pdf_summary_box_not_auto_included(self):
"""A summary box that happens to parse as one transaction must not start included.
The "Payment Due Date / Total Dues / Minimum Amount Due" block on an HDFC
credit-card statement has a date column and a figures column, so it yields a single
transaction - the due date and the minimum amount - and used to import as a phantom
row. What it does not have, and a real transaction table always does, is a narration.
"""
summary_box = {
"header_index": 1,
"rows": [
["Statement Date:17/08/2025", "Card No: 4341 55XX XXXX 2754", ""],
["Payment Due Date", "Total Dues", "Minimum Amount Due"],
["06/09/2025", "73,200.00", "3,660.00"],
["Credit Limit", "Available Credit Limit", "Available Cash Limit"],
["", "32,800", ""],
],
"column_mapping": [
{"index": 0, "header_text": "Payment Due Date", "variable": "a", "maps_to": "Date"},
{"index": 1, "header_text": "Total Dues", "variable": "b", "maps_to": "Do not import"},
{"index": 2, "header_text": "Minimum Amount Due", "variable": "c", "maps_to": "Amount"},
],
}
final, _df, _af = build_table_transactions(summary_box)
# It really does parse as a transaction - that is why the previous check missed it.
self.assertEqual(len(final), 1)
self.assertFalse(should_include_table(summary_box, final))
# The transaction table beside it, which does carry a narration, still starts included.
transactions = self._auto_map(
{
"rows": [
["Date", "Transaction Description", "Amount (in Rs.)"],
["21/07/2025", "ITC MAURYA NEW DELHI", "2,495.00"],
["27/07/2025", "PETRO SURCHARGE WAIVER", "50.90Cr"],
]
}
)
self.assertTrue(transactions["included"])
def test_pdf_table_without_description_still_importable(self):
"""No narration column means "starts unticked", NOT "cannot be imported".
`description` is not mandatory on Bank Transaction, so a bank that omits narration
must still import once the user ticks the table.
"""
table = {
"header_index": 0,
"rows": [
["Date", "Amount", "Balance"],
["01/04/2025", "500.00", "9,500.00"],
["03/04/2025", "20000.00", "29,500.00"],
],
"column_mapping": [
{"index": 0, "header_text": "Date", "variable": "a", "maps_to": "Date"},
{"index": 1, "header_text": "Amount", "variable": "b", "maps_to": "Amount"},
{"index": 2, "header_text": "Balance", "variable": "c", "maps_to": "Balance"},
],
}
final, _df, _af = build_table_transactions(table)
self.assertFalse(should_include_table(table, final))
# The transactions themselves are intact and importable.
self.assertEqual(len(final), 2)
self.assertEqual([t["date"] for t in final], ["2025-04-01", "2025-04-03"])
def test_headerless_content_mapping(self): def test_headerless_content_mapping(self):
"""Without a header row, columns are guessed from their contents.""" """Without a header row, columns are guessed from their contents."""
rows = [ rows = [