import { Combobox } from "@tailor-platform/app-shell"; import { useCallback } from "react"; import { useClient } from "urql"; import { graphql } from "@/graphql"; import { cn } from "@/lib/utils"; const AccountSearchQuery = graphql(` query AccountSearch($query: AccountQueryInput, $first: Int, $order: [AccountOrderInput]) { accounts(query: $query, first: $first, order: $order) { edges { node { id code name accountType status } } } } `); export interface AccountRef { id: string; code: string; name: string; accountType?: string; status?: string; } const formatAccountLabel = (account: AccountRef): string => `${account.code} — ${account.name}`; const DEBOUNCE_MS = 300; const PAGE_SIZE = 200; interface AccountComboboxProps { /** Currently selected account (controlled) */ value: AccountRef | null; /** Called when the selected account changes */ onValueChange: (account: AccountRef | null) => void; /** Placeholder text */ placeholder?: string; /** Additional className */ className?: string; /** Whether the combobox is disabled */ disabled?: boolean; /** Restrict results to one company. */ companyId?: string; /** Restrict results to one GL account type. */ accountType?: "ASSET" | "LIABILITY" | "EQUITY" | "REVENUE" | "EXPENSE"; } /** * Async searchable combobox for selecting GL Accounts (coa-management). * * Queries the backend with a debounced case-insensitive regex filter matching * either `code` or `name`, restricted to ACTIVE accounts. Results are labelled * "code — name". Mirrors CustomerCombobox / ItemCombobox. */ export const AccountCombobox = ({ value, onValueChange, placeholder = "Search accounts...", className, disabled, companyId, accountType, }: AccountComboboxProps) => { const client = useClient(); const fetcher = useCallback( async (input: string | null, { signal }: { signal: AbortSignal }) => { // When the dropdown reopens with the selected label still in the input, // treat it as an unfiltered open so the user sees the full list again. const selectedLabel = value ? formatAccountLabel(value) : null; const searchText = input === selectedLabel ? null : input; // Use case-insensitive regex — `contains` is case-sensitive on the backend. const textFilter = searchText ? { regex: `(?i)${searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` } : undefined; const baseFilters = [ { status: { eq: "ACTIVE" as const } }, ...(companyId ? [{ companyId: { eq: companyId } }] : []), ...(accountType ? [{ accountType: { eq: accountType } }] : []), ]; const query = { and: [ ...baseFilters, ...(textFilter ? [{ or: [{ code: textFilter }, { name: textFilter }] }] : []), ], }; const result = await client .query( AccountSearchQuery, { query, first: PAGE_SIZE, order: [{ field: "code" as const, direction: "Asc" as const }], }, { fetchOptions: { signal }, requestPolicy: "network-only" }, ) .toPromise(); return result.data?.accounts.edges.map((e) => e.node) ?? []; }, [accountType, client, companyId, value], ); return ( ({ label: formatAccountLabel(account), key: account.id })} placeholder={placeholder} className={cn("[&_[data-slot=combobox-input]]:pr-14", className)} disabled={disabled} /> ); };