whisper-money/resources/js/components/accounts/bank-combobox.tsx

208 lines
7.8 KiB
TypeScript

import { index as indexBanks } from '@/actions/App/Http/Controllers/Settings/BankController';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { type Bank } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Check, ChevronsUpDown, Plus } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
interface BankComboboxProps {
value: number | null;
onValueChange: (value: number | null) => void;
defaultBank?: Bank;
onCreateCustomBank?: (searchQuery: string) => void;
}
const bankCache = new Map<string, Bank[]>();
export function BankCombobox({
value,
onValueChange,
defaultBank,
onCreateCustomBank,
}: BankComboboxProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [banks, setBanks] = useState<Bank[]>(
defaultBank ? [defaultBank] : [],
);
const [isLoading, setIsLoading] = useState(false);
const [selectedBank, setSelectedBank] = useState<Bank | null>(
defaultBank || null,
);
const searchBanks = useCallback(
async (query: string) => {
if (query.length < 3) {
setBanks(defaultBank ? [defaultBank] : []);
return;
}
if (bankCache.has(query)) {
setBanks(bankCache.get(query)!);
return;
}
setIsLoading(true);
try {
const response = await fetch(
indexBanks.url({ query: { search: query } }),
{
headers: {
Accept: 'application/json',
},
},
);
if (!response.ok) {
throw new Error('Failed to search banks');
}
const data = await response.json();
const results = data.data || data;
bankCache.set(query, results);
setBanks(results);
} catch (error) {
console.error('Failed to search banks:', error);
setBanks([]);
} finally {
setIsLoading(false);
}
},
[defaultBank],
);
useEffect(() => {
const debounceTimer = setTimeout(() => {
searchBanks(searchQuery);
}, 300);
return () => clearTimeout(debounceTimer);
}, [searchQuery, searchBanks]);
const handleSelect = (bank: Bank) => {
setSelectedBank(bank);
onValueChange(bank.id);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
data-testid="bank-select"
>
{selectedBank ? (
<div className="flex items-center gap-2">
{selectedBank.logo ? (
<img
src={selectedBank.logo}
alt={selectedBank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{selectedBank.name}</span>
</div>
) : (
__('Select bank...')
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command filter={() => 1}>
<CommandInput
placeholder={__('Search bank...')}
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>
{isLoading
? __('Searching...')
: searchQuery.length < 3
? __('Type at least 3 characters to search')
: __('No bank found.')}
</CommandEmpty>
<CommandGroup>
{banks.map((bank) => (
<CommandItem
key={bank.id}
value={`${bank.id}-${bank.name}`}
onSelect={() => handleSelect(bank)}
>
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{bank.name}</span>
</div>
<Check
className={cn(
'ml-auto h-4 w-4',
value === bank.id
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
{onCreateCustomBank &&
searchQuery.length >= 3 &&
!isLoading &&
banks.length < 3 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
value="create-custom-bank"
onSelect={() => {
onCreateCustomBank(searchQuery);
setOpen(false);
}}
>
<Plus className="mr-2 h-4 w-4" />
<span>
{__('Create "')}
{searchQuery}&quot;
</span>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}