mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 15:29:30 +00:00
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:
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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())}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
23
banking/src/hooks/useResetScrollOnSearch.ts
Normal file
23
banking/src/hooks/useResetScrollOnSearch.ts
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
94
banking/src/styles/scroll-fade.css
Normal file
94
banking/src/styles/scroll-fade.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user