mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 23:18:40 +00:00
* feat: new banking module (#54720)
* feat: initial SPA setup for banking
* wip: bring over new banking module
* feat: added Espresso design tokens
* feat: button styles
* fix: add all ink colors
* wip: espresso design system changes
* feat: button and badge espresso components
* fix: button styling for reconcile
* feat: Espresso progress bar
* feat: Espresso toggle switch
* feat: Espresso tabs design
* fix: vertical tab support
* fix: button sizing across modals
* feat: Espresso style table layout
* feat: Espresso tooltip
* feat: Espresso elevations and checkbox
* feat: Dialog with Espresso styles
* feat: Espresso textarea
* fix: input styles
* fix: colors on bank picker
* fix: breadcrumb styling
* fix: bank picker styling
* feat: create doctypes and fields for bank reconciliation
* feat: APIs for banking
* fix: use date format parser
* fix: font styling to match Espresso
* wip: settings modal
* feat: settings dialog component
* fix: icons and invalid requests
* feat: preferences tab
* fix: adjust icon stroke width to 1.5
* feat: rule configuration in settings
* fix: remove sheet component
* feat: alert and error banner component
* feat: dropdown in Espresso
* feat: popover and select in Espresso
* fix: cleanup more styles
* fix: match size of link fields
* feat: command styling
* fix: remove unused style tokens
* fix: styles for global date picker dropdown
* fix: styles for match and reconcile
* feat: table Espresso component
* feat: remove all other design tokens
* fix: remove unused tokens
* fix: form elements
* fix: remove unused styles and fix filters in bank transaction list
* feat: fetch bank rec doctypes for filtering
* fix: record payment modal
* feat: support for dark mode switching
* fix: move bank logos to public folder
* feat: add support for RTL
* feat: support for RTL
* chore: send layout direction in dev boot
* fix: make checkbox work in RTL
* feat: dark mode support
* fix: dark mode style
* feat: bank logos in dark mode
* feat: dark mode bank logos
* chore: use dark mode bank logos everywhere
* chore: move rule evaluation to controller
* chore: add tests for bank transaction rules
* fix: move deps to fix actions errors
* fix: move tw-animate-css to deps
* fix: remove shadcn
* fix: do not open modal if no transactions selected
* fix: add translation strings
* feat: add banner on existing bank reconciliation tool
* feat: bank statement import
* fix: translations and layout directions
* fix: validation for transaction matching rule
* fix: styles
* fix: show conflicting transactions in alert
* fix: show help text for new banking module forms
* feat: show total debits and credits
* fix: dark mode colors in automatic config
* feat: add keyboard shortcuts help
* feat: added keyboard shortcut for settings
* fix: decrease size of progress bar
* chore: bump packages
* feat: add tests for statement import
* fix: settings dialog
* fix: show banner on small screens
* fix: show banner when no bank account set
(cherry picked from commit 6de5367f12)
# Conflicts:
# erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
* chore: resolve conflicts
* fix: add type hints to whitelisted methods
---------
Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
228 lines
8.5 KiB
TypeScript
228 lines
8.5 KiB
TypeScript
import { Button } from "@/components/ui/button"
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
|
import _ from "@/lib/translate"
|
|
import { cn } from "@/lib/utils"
|
|
import { useFrappeGetDocList } from "frappe-react-sdk"
|
|
import Fuse from "fuse.js"
|
|
import { ChevronDownIcon } from "lucide-react"
|
|
import { useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
import { FormControl } from "../ui/form"
|
|
|
|
|
|
export interface AccountsDropdownProps {
|
|
root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[],
|
|
report_type?: 'Balance Sheet' | 'Profit and Loss',
|
|
account_type?: string[],
|
|
value?: string,
|
|
onChange?: (value: string) => void,
|
|
readOnly?: boolean,
|
|
disabled?: boolean,
|
|
company?: string,
|
|
filterFunction?: (account: Account) => boolean,
|
|
// If true, the component will be wrapped in a FormControl component
|
|
useInForm?: boolean,
|
|
buttonClassName?: string,
|
|
size?: 'sm' | 'md' | 'lg',
|
|
}
|
|
/**
|
|
* Component to select an account - supports fuzzy search
|
|
* @param root_type - The root type of the account
|
|
* @param report_type - The report type of the account
|
|
* @param account_type - The type of the account
|
|
* @param value - The value of the account field
|
|
* @param onChange - The function to call when the value changes
|
|
* @returns
|
|
*/
|
|
const AccountsDropdown = ({ root_type, report_type, account_type, value, onChange, readOnly, disabled, company, filterFunction, useInForm, buttonClassName, size = 'md' }: AccountsDropdownProps) => {
|
|
|
|
const { data } = useGetAccounts(root_type, report_type, account_type, company, filterFunction)
|
|
|
|
const groupedAccounts = useMemo(() => {
|
|
if (!data) return []
|
|
|
|
const grouped: Record<string, Account[]> = data.reduce((acc, account) => {
|
|
const parentAccount = account.parent_account
|
|
if (!parentAccount) return acc
|
|
|
|
if (!acc[parentAccount]) {
|
|
acc[parentAccount] = []
|
|
}
|
|
|
|
acc[parentAccount].push(account)
|
|
return acc
|
|
}, {} as Record<string, Account[]>)
|
|
|
|
|
|
return Object.entries(grouped).map(([parentAccount, accounts]) => ({
|
|
// Remove the last abbreviation from the parent account name like "Assets - TCC" should be "Assets", and "Assets - USD - TCC" should be "Assets - USD"
|
|
parentAccount: parentAccount.split(" - ").slice(0, -1).join(" - "),
|
|
accounts
|
|
}))
|
|
|
|
}, [data])
|
|
|
|
const searchIndex = useMemo(() => {
|
|
|
|
if (!data) {
|
|
return null
|
|
}
|
|
|
|
return new Fuse(data, {
|
|
keys: ['name'],
|
|
threshold: 0.5,
|
|
includeScore: true
|
|
})
|
|
}, [data])
|
|
|
|
const [search, setSearch] = useState("")
|
|
|
|
const recommendedAccounts = useMemo(() => {
|
|
|
|
if (!searchIndex || !search) {
|
|
return []
|
|
}
|
|
|
|
return searchIndex.search(search).map((result) => result.item)
|
|
|
|
}, [searchIndex, search])
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const onOpenChange = (open: boolean) => {
|
|
if (readOnly) return
|
|
setOpen(open)
|
|
// setSearch("")
|
|
}
|
|
|
|
const onSelect = (value: string) => {
|
|
onChange?.(value)
|
|
setOpen(false)
|
|
setSearch(value)
|
|
}
|
|
|
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
|
|
const [width, setWidth] = useState(320)
|
|
|
|
useLayoutEffect(() => {
|
|
if (buttonRef.current) {
|
|
setWidth(buttonRef.current.getBoundingClientRect().width)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
|
<PopoverTrigger asChild>
|
|
{useInForm ? <FormControl>
|
|
<Button
|
|
variant="subtle"
|
|
type='button'
|
|
size={size}
|
|
role="combobox"
|
|
ref={buttonRef}
|
|
tabIndex={0}
|
|
disabled={disabled || readOnly}
|
|
aria-readonly={readOnly}
|
|
aria-expanded={open}
|
|
className={cn("w-full justify-between font-normal",
|
|
readOnly ? "bg-surface-gray-1 pointer-events-none" : ""
|
|
, buttonClassName)}>
|
|
{value || _('Select Account')}
|
|
|
|
<ChevronDownIcon className="ms-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</FormControl>
|
|
: <Button
|
|
variant="subtle"
|
|
size={size}
|
|
type='button'
|
|
role="combobox"
|
|
ref={buttonRef}
|
|
disabled={disabled}
|
|
aria-expanded={open}
|
|
className={cn("w-full justify-between font-normal",
|
|
readOnly ? "bg-surface-gray-1" : ""
|
|
)}>
|
|
{value || _('Select Account')}
|
|
|
|
<ChevronDownIcon className="ms-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>}
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
|
|
<Command shouldFilter={false} className="w-full">
|
|
<CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} />
|
|
<CommandList>
|
|
<CommandEmpty>{_("No accounts found.")}</CommandEmpty>
|
|
|
|
{recommendedAccounts.length > 0 && (
|
|
<CommandGroup heading={_("Search Results")}>
|
|
{recommendedAccounts.map((account) => (
|
|
<CommandItem key={account.name} onSelect={() => onSelect(account.name)}>{account.name}</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{!search && groupedAccounts.map((group) => (
|
|
<CommandGroup key={group.parentAccount} heading={group.parentAccount}>
|
|
{group.accounts.map((account) => (
|
|
<CommandItem key={account.name} onSelect={() => onSelect(account.name)}>{account.name}</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
))}
|
|
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
|
|
interface Account {
|
|
name: string
|
|
root_type: 'Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense'
|
|
report_type: 'Balance Sheet' | 'Profit and Loss'
|
|
account_type: string
|
|
account_currency: string
|
|
parent_account: string
|
|
}
|
|
|
|
export const useGetAccounts = (root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[], report_type?: 'Balance Sheet' | 'Profit and Loss', account_type?: string[], company?: string,
|
|
filterFunction?: (account: Account) => boolean) => {
|
|
|
|
const currentCompany = useCurrentCompany()
|
|
const { data, isLoading, error, mutate } = useFrappeGetDocList<Account>("Account", {
|
|
fields: ["name", "root_type", "report_type", "account_type", "account_currency", "parent_account"],
|
|
filters: [["is_group", "=", 0], ["disabled", "=", 0], ["company", "=", company ?? currentCompany]],
|
|
limit: 1000,
|
|
orderBy: {
|
|
"field": "root_type",
|
|
// @ts-expect-error - we can pass in additional fields to orderBy
|
|
"order": "asc, account_number asc"
|
|
}
|
|
}, `accounts-${company ?? currentCompany}`, {
|
|
revalidateIfStale: false,
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
})
|
|
|
|
const filteredData = useMemo(() => {
|
|
|
|
return data?.filter((account) => {
|
|
if (root_type && !root_type.includes(account.root_type)) return false
|
|
if (report_type && account.report_type !== report_type) return false
|
|
if (account_type && !account_type.includes(account.account_type)) return false
|
|
|
|
if (filterFunction) return filterFunction(account)
|
|
return true
|
|
}) ?? []
|
|
|
|
}, [data, root_type, report_type, account_type, filterFunction])
|
|
|
|
return { data: filteredData, isLoading, error, mutate }
|
|
}
|
|
|
|
export default AccountsDropdown |