mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 14:41:53 +00:00
* 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
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
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 |