mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-17 08:28:44 +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
This commit is contained in:
228
banking/src/components/common/AccountsDropdown.tsx
Normal file
228
banking/src/components/common/AccountsDropdown.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
26
banking/src/components/common/BankLogo.tsx
Normal file
26
banking/src/components/common/BankLogo.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectedBank } from '../features/BankReconciliation/bankRecAtoms'
|
||||
import { useTheme } from '../ui/theme-provider'
|
||||
import { Landmark } from 'lucide-react'
|
||||
import { H4 } from '../ui/typography'
|
||||
|
||||
const BankLogo = ({ bank, className, imageClassName, iconSize = '18px', iconClassName }: { bank?: SelectedBank | null, className?: string, imageClassName?: string, iconSize?: string, iconClassName?: string }) => {
|
||||
|
||||
const { themeValue } = useTheme()
|
||||
return (
|
||||
<div className={cn('h-6 flex items-center gap-1', className)}> {bank?.logo ? <img
|
||||
src={`/assets/erpnext/images/bank-logos/${themeValue === 'Dark' ? (bank.logoDark ?? bank.logo) : bank.logo}`}
|
||||
alt={bank.bank || bank.name || ''}
|
||||
className={cn("h-6 max-w-22 me-auto object-contain", imageClassName, {
|
||||
'dark:invert dark:brightness-0': bank.darkModeInvert
|
||||
}, bank.logoClassName)}
|
||||
/> : <>
|
||||
<Landmark size={iconSize} className={iconClassName} />
|
||||
<H4 className={cn("text-xs -mb-0.5", {
|
||||
})}>{bank?.bank ?? ''}</H4>
|
||||
</>
|
||||
}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankLogo
|
||||
17
banking/src/components/common/FileUploadBanner.tsx
Normal file
17
banking/src/components/common/FileUploadBanner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { Progress } from '../ui/progress'
|
||||
import _ from '@/lib/translate'
|
||||
|
||||
const FileUploadBanner = ({
|
||||
uploadProgress,
|
||||
}: { uploadProgress: number }) => {
|
||||
return <div className="flex items-center justify-center flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<CheckCircle size={48} className="text-ink-green-3" />
|
||||
<span className="text-ink-gray-8 text-p-base">{_("The document has been created and reconciled. Uploading attachments...")}</span>
|
||||
<Progress value={Math.round(uploadProgress * 100)} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default FileUploadBanner
|
||||
301
banking/src/components/common/LinkFieldCombobox.tsx
Normal file
301
banking/src/components/common/LinkFieldCombobox.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useDocType } from "@/hooks/useDocType";
|
||||
import { getSystemDefault, slug } from "@/lib/frappe";
|
||||
import { Filter, useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { canCreateDocument } from "@/lib/permissions";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { FormControl } from "../ui/form";
|
||||
import { ChevronDownIcon, ExternalLink } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command";
|
||||
import _ from "@/lib/translate";
|
||||
import ErrorBanner from "../ui/error-banner";
|
||||
import MarkdownRenderer from "../ui/markdown";
|
||||
|
||||
export interface ResultItem {
|
||||
value: string,
|
||||
description: string,
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface LinkFieldComboboxProps {
|
||||
/** DocType to be fetched */
|
||||
doctype: string;
|
||||
/** Filters to be applied. Default: none */
|
||||
filters?: Filter[]
|
||||
/** Number of records to paginate with. Default: Comes from System Settings or 10 */
|
||||
limit?: number;
|
||||
/**
|
||||
* API to call to fetch records.
|
||||
*
|
||||
* Default: `frappe.desk.search.search_link`
|
||||
*
|
||||
* If you want to use a custom API, you can pass the path to the API here.
|
||||
*
|
||||
* The API should return a list of documents in the following format:
|
||||
* [{value: string, description: string, label?: string}] - where the value is the ID of the document.
|
||||
*
|
||||
* If the API sends a label, it will be used as the label in the dropdown.
|
||||
*/
|
||||
searchAPIPath?: string;
|
||||
/**
|
||||
* Field you want to search against in the doctype.
|
||||
*
|
||||
* Default: `name`
|
||||
*
|
||||
* If you want to search against a different field, you can pass the fieldname here.
|
||||
*
|
||||
* If you want to search against multiple fields, you can try using the `searchAPIPath` prop to call a custom API,
|
||||
* or use a custom query in the `customQuery` prop.
|
||||
*/
|
||||
searchfield?: string;
|
||||
/**
|
||||
* Custom query to be used to fetch records.
|
||||
*
|
||||
* If you want to use a custom query, you can pass the query here.
|
||||
*
|
||||
* The query should be in the following format:
|
||||
* {
|
||||
* query: string,
|
||||
* filters: {
|
||||
* fieldname: string,
|
||||
* operator: string,
|
||||
* value: string
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
customQuery?: {
|
||||
/** Path to function for the query.
|
||||
*
|
||||
* Refer: Item/Supplier query
|
||||
*/
|
||||
query: string,
|
||||
/** Filters are usually an object instead of an array in a custom query */
|
||||
filters?: Record<string, string | number | boolean>,
|
||||
},
|
||||
/**
|
||||
* Used for certain queries where a reference doctype is needed.
|
||||
*
|
||||
* For example when searching a supplier in a "Purchase Invoice", the reference_doctype is "Purchase Invoice"
|
||||
*/
|
||||
reference_doctype?: string,
|
||||
/** Placeholder for the dropdown. Default: `doctype` */
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Should the field be read-only.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/** Should the field be disabled. Default: false */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Function to filter the options based on the input value/other criteria.
|
||||
*
|
||||
* For example, you might want to limit the companies shown in the dropdown since they have been already added (like in Cost Codes)
|
||||
*/
|
||||
filterFn?: (option: ResultItem, inputValue: string) => boolean,
|
||||
value?: string,
|
||||
onChange: (value: string) => void,
|
||||
/** If true, the component will be wrapped in a FormControl component */
|
||||
useInForm?: boolean,
|
||||
/** Button Class name */
|
||||
buttonClassName?: string,
|
||||
size?: 'sm' | 'md' | 'lg',
|
||||
}
|
||||
const LinkFieldCombobox = ({
|
||||
doctype,
|
||||
reference_doctype,
|
||||
filters = [],
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
disabled,
|
||||
filterFn,
|
||||
placeholder = `Select ${doctype}`,
|
||||
customQuery,
|
||||
searchfield,
|
||||
searchAPIPath = "frappe.desk.search.search_link",
|
||||
limit,
|
||||
useInForm,
|
||||
buttonClassName,
|
||||
size = 'md'
|
||||
}: LinkFieldComboboxProps) => {
|
||||
|
||||
const pageLimit = useMemo(() => limit || getSystemDefault('link_field_results_limit') || 20, [limit])
|
||||
|
||||
/** Load the Doctype meta so that we can determine the search fields + the name of the title field */
|
||||
const { data: meta } = useDocType(doctype)
|
||||
|
||||
const userCanCreate = useMemo(() => canCreateDocument(doctype), [doctype])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [searchInput, setSearchInput] = useDebounceValue('', 400)
|
||||
|
||||
const { data: linkTitleData } = useFrappeGetCall('frappe.client.get_value', {
|
||||
doctype,
|
||||
filters: JSON.stringify({
|
||||
name: value
|
||||
}),
|
||||
fieldname: meta?.title_field
|
||||
}, (meta?.show_title_field_in_link ?? false) && (meta?.title_field) && value ? `link_title::${doctype}::${value}` : null, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const linkTitle = meta?.title_field && meta?.show_title_field_in_link ? (linkTitleData?.message?.[meta?.title_field] ?? value) : value
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [width, setWidth] = useState(320)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (buttonRef.current) {
|
||||
setWidth(buttonRef.current.getBoundingClientRect().width)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { data, error, isLoading } = useFrappeGetCall<{ message: ResultItem[] }>(searchAPIPath, {
|
||||
doctype,
|
||||
txt: searchInput,
|
||||
page_length: pageLimit,
|
||||
query: customQuery?.query,
|
||||
searchfield,
|
||||
filters: JSON.stringify(customQuery?.filters || filters || []),
|
||||
reference_doctype,
|
||||
}, () => {
|
||||
if (!open) {
|
||||
return null
|
||||
} else {
|
||||
let key = `${searchAPIPath}_${doctype}_${searchInput}`
|
||||
|
||||
if (pageLimit) {
|
||||
key += `_${pageLimit}`
|
||||
}
|
||||
|
||||
if (customQuery?.filters) {
|
||||
key += `_${JSON.stringify(customQuery.filters)}`
|
||||
} else if (filters) {
|
||||
key += `_${JSON.stringify(filters)}`
|
||||
}
|
||||
|
||||
if (customQuery && customQuery.query) {
|
||||
key += `_${customQuery.query}`
|
||||
}
|
||||
|
||||
if (reference_doctype) {
|
||||
key += `_${reference_doctype}`
|
||||
}
|
||||
|
||||
if (searchfield && searchfield !== 'name') {
|
||||
key += `_${searchfield}`
|
||||
}
|
||||
|
||||
return key
|
||||
|
||||
}
|
||||
}, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
shouldRetryOnError: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (readOnly) return
|
||||
setOpen(open)
|
||||
setSearchInput("")
|
||||
}
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
onChange?.(value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const items = filterFn ? data?.message?.slice(0, 50).filter((item) => filterFn(item, searchInput)) : data?.message
|
||||
|
||||
const buttonProps = {
|
||||
variant: "subtle",
|
||||
type: 'button',
|
||||
size: size,
|
||||
role: "combobox",
|
||||
"data-state": open ? "open" : "closed",
|
||||
ref: buttonRef,
|
||||
tabIndex: 0,
|
||||
disabled: disabled || readOnly,
|
||||
"aria-expanded": open,
|
||||
"aria-readonly": readOnly,
|
||||
className: cn("w-full justify-between font-normal group border border-transparent outline-none",
|
||||
"data-[state=open]:bg-surface-white data-[state=open]:border-outline-gray-4 data-[state=open]:shadow-sm",
|
||||
readOnly ? "bg-surface-gray-1" : "",
|
||||
// Placeholder and value styling
|
||||
linkTitle ? "text-ink-gray-7" : "text-ink-gray-4",
|
||||
buttonClassName)
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
{useInForm ? <FormControl>
|
||||
<Button {...buttonProps}>
|
||||
{linkTitle || placeholder}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{value && <a href={`/desk/${slug(doctype)}/${value}`} target="_blank" className="group-hover:block hidden">
|
||||
<ExternalLink className="size-4 shrink-0 opacity-50" />
|
||||
</a>}
|
||||
<ChevronDownIcon className="ms-2 size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</FormControl>
|
||||
: <Button {...buttonProps}>
|
||||
{linkTitle || placeholder}
|
||||
<div className="flex items-center gap-1">
|
||||
{value && <a href={`/desk/${slug(doctype)}/${value}`} target="_blank" className="group-hover:block hidden">
|
||||
<ExternalLink className="size-4 shrink-0 opacity-50" />
|
||||
</a>}
|
||||
<ChevronDownIcon className="ms-2 size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{items?.map((result) => (
|
||||
<CommandItem key={result.value} onSelect={() => onSelect(result.value)} className="flex flex-col items-start gap-0.5">
|
||||
<span className="font-medium">
|
||||
{result.label || result.value}
|
||||
</span>
|
||||
{result.description && <span className="text-xs text-ink-gray-5">
|
||||
<MarkdownRenderer content={result.description} />
|
||||
</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
{userCanCreate && <CommandItem asChild>
|
||||
<a href={`/desk/${slug(doctype)}/new-${slug(doctype)}-1`}
|
||||
target="_blank"
|
||||
className="hover:underline underline-offset-4 cursor-pointer flex justify-between items-center">
|
||||
{_("Create New {0}", [doctype])}
|
||||
|
||||
<ExternalLink />
|
||||
</a>
|
||||
|
||||
</CommandItem>}
|
||||
</CommandGroup>
|
||||
|
||||
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkFieldCombobox
|
||||
82
banking/src/components/common/PartyTypeDropdown.tsx
Normal file
82
banking/src/components/common/PartyTypeDropdown.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select'
|
||||
import _ from '@/lib/translate'
|
||||
import { useFrappeGetDocList } from 'frappe-react-sdk'
|
||||
import { ComponentProps, useMemo } from 'react'
|
||||
import { FormControl } from '../ui/form'
|
||||
|
||||
export type PartyTypeDropdownProps = {
|
||||
value?: string,
|
||||
onChange?: (value: string) => void,
|
||||
readOnly?: boolean,
|
||||
disabled?: boolean,
|
||||
/** Set this to order the parties so that suggested types are shown first */
|
||||
type?: 'Receivable' | 'Payable'
|
||||
/** Set this to true if you want to hide other options by type. e.g. - if type is Receivable, Payable options like "Supplier" will be hidden */
|
||||
hideOptionsByType?: boolean,
|
||||
valueProps?: ComponentProps<typeof SelectValue>,
|
||||
triggerProps?: ComponentProps<typeof SelectTrigger>,
|
||||
// If true, the component will be wrapped in a FormControl component
|
||||
useInForm?: boolean
|
||||
}
|
||||
|
||||
const PartyTypeDropdown = ({ value, onChange, readOnly, disabled, type, hideOptionsByType, valueProps, triggerProps, useInForm }: PartyTypeDropdownProps) => {
|
||||
|
||||
const { data } = useFrappeGetDocList("Party Type", {
|
||||
fields: ['name', 'account_type'],
|
||||
orderBy: {
|
||||
field: 'creation',
|
||||
order: 'asc'
|
||||
}
|
||||
}, `party_types`, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
|
||||
let options = data ?? [
|
||||
{ name: "Customer", account_type: "Receivable" },
|
||||
{ name: "Supplier", account_type: "Payable" },
|
||||
{ name: "Employee", account_type: "Payable" },
|
||||
{ name: "Shareholder", account_type: "Payable" },
|
||||
]
|
||||
|
||||
if (hideOptionsByType && type) {
|
||||
options = options.filter((option) => option.account_type === type)
|
||||
}
|
||||
|
||||
// Order by type if type is set
|
||||
if (type) {
|
||||
options = options.sort((a) => a.account_type === type ? -1 : 1)
|
||||
}
|
||||
|
||||
return options
|
||||
}, [data, type, hideOptionsByType])
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
if (!readOnly) {
|
||||
onChange?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select onValueChange={onSelect} value={value} disabled={disabled}>
|
||||
{useInForm ? <FormControl>
|
||||
<SelectTrigger tabIndex={0} aria-readonly={readOnly} disabled={disabled || readOnly} {...triggerProps}>
|
||||
<SelectValue placeholder={_("Type")} aria-readonly={readOnly} {...valueProps} />
|
||||
</SelectTrigger>
|
||||
</FormControl> : <SelectTrigger tabIndex={0} {...triggerProps}>
|
||||
<SelectValue placeholder={_("Type")} aria-readonly={readOnly} {...valueProps} />
|
||||
</SelectTrigger>
|
||||
}
|
||||
<SelectContent>
|
||||
{filteredData.map((option) => (
|
||||
<SelectItem key={option.name} value={option.name}>{option.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartyTypeDropdown
|
||||
Reference in New Issue
Block a user