fix: UI/UX issues in new banking module (#54824)

* fix: enforce user permissions on bank account get_list

* feat: auto-select last used bank account

* fix: skeleton loaders in bank balance

* fix: show empty state for no bank transactions

* chore: add Stripe and PayPal logos

* fix: alignment of header text in list-view

* fix: wrap words in transaction description

* fix: change file-dropzone color on hover
This commit is contained in:
Nikhil Kothari
2026-05-11 13:02:11 +05:30
committed by GitHub
parent 03acbc3dc9
commit f4008adc16
11 changed files with 99 additions and 66 deletions

View File

@@ -53,7 +53,7 @@ const OpeningBalance = () => {
return <StatContainer className="min-w-48"> return <StatContainer className="min-w-48">
<StatLabel>{_("Opening Balance")}</StatLabel> <StatLabel>{_("Opening Balance")}</StatLabel>
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>} {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>}
</StatContainer> </StatContainer>
} }
@@ -86,7 +86,7 @@ const ClosingBalance = () => {
</HoverCard> </HoverCard>
</div> </div>
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>} {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>}
</StatContainer> </StatContainer>
) )
} }
@@ -104,7 +104,7 @@ const Difference = () => {
return <StatContainer className="w-fit text-end sm:min-w-56"> return <StatContainer className="w-fit text-end sm:min-w-56">
<StatLabel className="text-end">{_("Difference")}</StatLabel> <StatLabel className="text-end">{_("Difference")}</StatLabel>
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}> {isLoading ? <Skeleton className="w-[150px] h-5 self-end rounded-sm" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
{formatCurrency(difference, {formatCurrency(difference,
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')) bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
}</StatValue>} }</StatValue>}
@@ -175,7 +175,7 @@ const ClosingBalanceAsPerStatement = () => {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button"> <div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
{isLoading ? <Skeleton className="w-[150px] h-9" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>} {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" /> <Edit className="w-4 h-4" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>

View File

@@ -1,4 +1,4 @@
import { useAtom, useSetAtom } from "jotai" import { useAtom } from "jotai"
import { SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" import { SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCallback } from "react" import { useCallback } from "react"
import { useGetBankAccounts, useGetUnreconciledTransactions } from "./utils" import { useGetBankAccounts, useGetUnreconciledTransactions } from "./utils"
@@ -16,9 +16,16 @@ import { useCurrentCompany } from "@/hooks/useCurrentCompany"
const BankPicker = ({ className }: { className?: string }) => { const BankPicker = ({ className }: { className?: string }) => {
const setSelectedBank = useSetAtom(selectedBankAccountAtom) const [selectedBank, setSelectedBank] = useAtom(selectedBankAccountAtom)
const onLoadingSuccess = useCallback((data?: SelectedBank[]) => { const onLoadingSuccess = useCallback((data?: SelectedBank[]) => {
// If the bank is already selected, then don't set it again
if (selectedBank) {
// Check if selected bank is in the data
if (data?.some((bank: SelectedBank) => bank.name === selectedBank.name)) {
return
}
}
if (!data) return if (!data) return
if (data.length === 1) { if (data.length === 1) {
setSelectedBank(data[0]) setSelectedBank(data[0])
@@ -26,9 +33,12 @@ const BankPicker = ({ className }: { className?: string }) => {
const defaultBank = data.find((bank: SelectedBank) => bank.is_default) const defaultBank = data.find((bank: SelectedBank) => bank.is_default)
if (defaultBank) { if (defaultBank) {
setSelectedBank(defaultBank) setSelectedBank(defaultBank)
} else {
// Select the first available bank account
setSelectedBank(data[0])
} }
} }
}, [setSelectedBank]) }, [setSelectedBank, selectedBank])
const selectedCompany = useCurrentCompany() const selectedCompany = useCurrentCompany()

View File

@@ -21,7 +21,7 @@ import { useDebounceValue } from "usehooks-ts"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { Link } from "react-router" import { Link } from "react-router"
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription } from "@/components/ui/empty" import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group" import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
const BankTransactions = () => { const BankTransactions = () => {
@@ -262,7 +262,7 @@ const BankTransactionListView = () => {
{error && <ErrorBanner error={error} />} {error && <ErrorBanner error={error} />}
{data && data.message.length > 0 && <Filters <Filters
onSearchChange={onSearchChange} onSearchChange={onSearchChange}
search={search} search={search}
results={filteredResults} results={filteredResults}
@@ -272,28 +272,31 @@ const BankTransactionListView = () => {
typeFilter={typeFilter} typeFilter={typeFilter}
status={status} status={status}
setStatus={setStatus} setStatus={setStatus}
/>} />
{data && data.message.length > 0 ? (
<ListView
data={filteredResults}
columns={transactionColumns}
getRowId={(row) => row.name}
maxHeight="calc(100vh - 200px)"
scrollAreaClassName="min-h-[calc(100vh-200px)]"
emptyState={<Empty>
<EmptyMedia>
<ListIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No bank transactions found")}</EmptyTitle>
<EmptyDescription>{_("There are no transactions in the system for the selected bank account and dates that match the filters.")}</EmptyDescription>
</EmptyHeader>
</Empty>}
/>
) : null}
<ListView
data={filteredResults}
columns={transactionColumns}
getRowId={(row) => row.name}
maxHeight="calc(100vh - 200px)"
scrollAreaClassName="min-h-[calc(100vh-200px)]"
emptyState={<Empty>
<EmptyMedia>
<ListIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No bank transactions found")}</EmptyTitle>
<EmptyDescription>{_("There are no transactions in the system for the selected bank account and dates that match the filters.")}</EmptyDescription>
</EmptyHeader>
{data && data.message.length === 0 ? <EmptyContent>
<Button type='button' asChild variant='outline'>
<Link to="/statement-importer">
{_("Import Bank Statement")}
</Link>
</Button>
</EmptyContent> : null}
</Empty>}
/>
</div> </div>
} }

View File

@@ -298,7 +298,7 @@ const UnreconciledTransactionItem = ({ transaction }: { transaction: Unreconcile
tabIndex={0} tabIndex={0}
onClick={handleSelectTransaction}> onClick={handleSelectTransaction}>
<div className="flex justify-between items-start w-full"> <div className="flex justify-between items-start w-full">
<div className="space-y-1"> <div className="space-y-1 overflow-hidden whitespace-pre-wrap">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-medium text-sm">{formatDate(transaction.date)}</span> <span className="font-medium text-sm">{formatDate(transaction.date)}</span>
{transaction.transaction_type && {transaction.transaction_type &&
@@ -314,7 +314,7 @@ const UnreconciledTransactionItem = ({ transaction }: { transaction: Unreconcile
title={_("Matched by rule")}> title={_("Matched by rule")}>
<ZapIcon className="w-4 h-4" /> {transaction.matched_transaction_rule}</Badge>} <ZapIcon className="w-4 h-4" /> {transaction.matched_transaction_rule}</Badge>}
</div> </div>
<span className="text-sm">{transaction.description}</span> <span className="text-sm wrap-anywhere" title={transaction.description}>{transaction.description}</span>
</div> </div>
<div className="gap-1 flex flex-col items-end min-w-36 h-full text-end"> <div className="gap-1 flex flex-col items-end min-w-36 h-full text-end">
{isWithdrawal ? <ArrowUpRight className="size-5 text-ink-red-3" /> : <ArrowDownRight className="size-5 text-ink-green-3" />} {isWithdrawal ? <ArrowUpRight className="size-5 text-ink-red-3" /> : <ArrowDownRight className="size-5 text-ink-green-3" />}

View File

@@ -15,7 +15,9 @@ export interface SelectedBank extends Pick<BankAccount, 'name' | 'bank' | 'is_cr
logoClassName?: string, logoClassName?: string,
account_currency?: string account_currency?: string
} }
export const selectedBankAccountAtom = atom<SelectedBank | null>(null) export const selectedBankAccountAtom = atomWithStorage<SelectedBank | null>('bank-rec-selected-bank', null, undefined, {
getOnInit: true
})
export const bankRecDateAtom = atomWithStorage<{ fromDate: string, toDate: string }>("bank-rec-date", { export const bankRecDateAtom = atomWithStorage<{ fromDate: string, toDate: string }>("bank-rec-date", {
fromDate: getDatesForTimePeriod('This Month').fromDate, fromDate: getDatesForTimePeriod('This Month').fromDate,

View File

@@ -393,5 +393,18 @@ export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[],
logo: "Prime_Bank.png", logo: "Prime_Bank.png",
locale: ['Kenya'], locale: ['Kenya'],
logoClassName: 'max-w-28' logoClassName: 'max-w-28'
},
{
keywords: ["Stripe"],
logo: "Stripe.svg",
locale: ['Global'],
logoClassName: 'h-6',
darkModeInvert: true,
},
{
keywords: ["PayPal"],
logo: "PayPal.png",
locale: ['Global'],
logoClassName: 'h-6',
} }
] ]

View File

@@ -31,7 +31,7 @@ export const FileDropzone = ({ files, setFiles, accept, multiple = true, onDrop,
}, [setFiles, onDrop, multiple, onUpdate]) }, [setFiles, onDrop, multiple, onUpdate])
const { getRootProps, getInputProps } = useDropzone({ onDrop: onFileDrop, accept, multiple }) const { getRootProps, getInputProps } = useDropzone({ onDrop: onFileDrop, accept, multiple })
return ( return (
<div {...getRootProps()} className={cn('border border-outline-gray-2 border-dashed p-4 rounded bg-surface-gray-1 focus-within:bg-surface-gray-2 focus-within:border-outline-gray-4 focus-within:outline-none', className)}> <div {...getRootProps()} className={cn('border border-outline-gray-2 border-dashed p-4 rounded bg-surface-gray-1 focus-within:bg-surface-gray-2 hover:bg-surface-gray-2 hover:border-outline-gray-3 focus-within:border-outline-gray-3 focus-within:outline-none', className)}>
<input {...getInputProps()} /> <input {...getInputProps()} />
{files.length === 0 ? <p className='text-sm text-ink-gray-5 text-center h-8 flex items-center justify-center'>{multiple ? _("Drop some files here, or click to select files") : _("Drop a file here, or click to select a file")}</p> : null} {files.length === 0 ? <p className='text-sm text-ink-gray-5 text-center h-8 flex items-center justify-center'>{multiple ? _("Drop some files here, or click to select files") : _("Drop a file here, or click to select a file")}</p> : null}
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>

View File

@@ -382,7 +382,7 @@ function ListViewInner<TData>({
<div <div
key={header.id} key={header.id}
className={cn( className={cn(
"text-ink-gray-5 group relative flex min-w-0 items-center px-1 text-sm", "text-ink-gray-5 group relative flex min-w-0 items-center px-0 text-sm",
alignClass(meta), alignClass(meta),
)} )}
role="columnheader" role="columnheader"
@@ -397,7 +397,7 @@ function ListViewInner<TData>({
<span <span
aria-hidden aria-hidden
className={cn( className={cn(
"pointer-events-none absolute ltr:right-0 rtl:left-0 z-1 w-0.5 bg-gray-400", "pointer-events-none absolute ltr:-right-2 rtl:-left-2 z-1 w-0.5 bg-gray-400",
"opacity-0 transition-[opacity,background-color] ease-in-out duration-150", "opacity-0 transition-[opacity,background-color] ease-in-out duration-150",
"group-hover:opacity-100 group-hover:bg-gray-400", "group-hover:opacity-100 group-hover:bg-gray-400",
header.column.getIsResizing() && "bg-outline-gray-6 opacity-100", header.column.getIsResizing() && "bg-outline-gray-6 opacity-100",
@@ -421,7 +421,7 @@ function ListViewInner<TData>({
header.getResizeHandler()(e) header.getResizeHandler()(e)
}} }}
onTouchStart={header.getResizeHandler()} onTouchStart={header.getResizeHandler()}
className="absolute top-0 ltr:right-0 rtl:left-0 z-10 h-full w-2 max-w-[12px] cursor-col-resize touch-none select-none bg-transparent" className="absolute top-0 ltr:-right-2 rtl:-left-2 z-10 h-full w-2 max-w-[12px] cursor-col-resize touch-none select-none bg-transparent"
/> />
</> </>
) : null} ) : null}

View File

@@ -12,7 +12,6 @@ from frappe.contacts.address_and_contact import (
) )
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import comma_and, get_link_to_form from frappe.utils import comma_and, get_link_to_form
from pypika import Order
class BankAccount(Document): class BankAccount(Document):
@@ -139,38 +138,35 @@ def get_list(company: str, show_disabled: bool = False):
@return: A list of bank accounts @return: A list of bank accounts
""" """
frappe.has_permission("Bank Account", ptype="read", throw=True) filters = {"is_company_account": 1, "company": company}
if not show_disabled:
filters["disabled"] = 0
bank_account = frappe.qb.DocType("Bank Account") bank_accounts = frappe.get_list(
account = frappe.qb.DocType("Account") "Bank Account",
filters=filters,
query = ( order_by="is_default desc",
frappe.qb.from_(bank_account) fields=[
.join(account) "name",
.on(bank_account.account == account.name) "account",
.select( "company",
bank_account.name, "account_name",
account.account_currency, "is_default",
bank_account.account, "bank",
bank_account.company, "account_type",
bank_account.account_name, "account_subtype",
bank_account.is_default, "bank_account_no",
bank_account.bank, "last_integration_date",
bank_account.account_type, "is_credit_card",
bank_account.account_subtype, ],
bank_account.bank_account_no,
bank_account.last_integration_date,
bank_account.is_credit_card,
)
.where(bank_account.is_company_account == 1)
.where(bank_account.company == company)
.orderby(bank_account.is_default, order=Order.desc)
) )
if not show_disabled: for bank_account in bank_accounts:
query = query.where(bank_account.disabled == 0) bank_account.account_currency = frappe.get_cached_value(
"Account", bank_account.account, "account_currency"
)
return query.run(as_dict=True) return bank_accounts
@frappe.whitelist(methods=["GET"]) @frappe.whitelist(methods=["GET"])

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,9 @@
<svg width="360" height="150" viewBox="0 0 360 150" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M360 77.4001C360 51.8001 347.6 31.6001 323.9 31.6001C300.1 31.6001 285.7 51.8001 285.7 77.2001C285.7 107.3 302.7 122.5 327.1 122.5C339 122.5 348 119.8 354.8 116V96.0001C348 99.4001 340.2 101.5 330.3 101.5C320.6 101.5 312 98.1001 310.9 86.3001H359.8C359.8 85.0001 360 79.8001 360 77.4001ZM310.6 67.9001C310.6 56.6001 317.5 51.9001 323.8 51.9001C329.9 51.9001 336.4 56.6001 336.4 67.9001H310.6Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M247.1 31.6001C237.3 31.6001 231 36.2001 227.5 39.4001L226.2 33.2001H204.2V149.8L229.2 144.5L229.3 116.2C232.9 118.8 238.2 122.5 247 122.5C264.9 122.5 281.2 108.1 281.2 76.4001C281.1 47.4001 264.6 31.6001 247.1 31.6001ZM241.1 100.5C235.2 100.5 231.7 98.4001 229.3 95.8001L229.2 58.7001C231.8 55.8001 235.4 53.8001 241.1 53.8001C250.2 53.8001 256.5 64.0001 256.5 77.1001C256.5 90.5001 250.3 100.5 241.1 100.5Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M169.8 25.7L194.9 20.3V0L169.8 5.3V25.7Z" fill="#533AFD"/>
<path d="M194.9 33.3H169.8V120.8H194.9V33.3Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M142.9 40.7L141.3 33.3H119.7V120.8H144.7V61.5C150.6 53.8 160.6 55.2 163.7 56.3V33.3C160.5 32.1 148.8 29.9 142.9 40.7Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.8999 11.6001L68.4999 16.8001L68.3999 96.9001C68.3999 111.7 79.4999 122.6 94.2999 122.6C102.5 122.6 108.5 121.1 111.8 119.3V99.0001C108.6 100.3 92.7999 104.9 92.7999 90.1001V54.6001H111.8V33.3001H92.7999L92.8999 11.6001Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.3 58.7001C25.3 54.8001 28.5 53.3001 33.8 53.3001C41.4 53.3001 51 55.6001 58.6 59.7001V36.2001C50.3 32.9001 42.1 31.6001 33.8 31.6001C13.5 31.6001 0 42.2001 0 59.9001C0 87.5001 38 83.1001 38 95.0001C38 99.6001 34 101.1 28.4 101.1C20.1 101.1 9.5 97.7001 1.1 93.1001V116.9C10.4 120.9 19.8 122.6 28.4 122.6C49.2 122.6 63.5 112.3 63.5 94.4001C63.4 64.6001 25.3 69.9001 25.3 58.7001Z" fill="#533AFD"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB