fix(입고등록): 품목코드/발주처 검색 API 연동 및 최소 입력 조건 추가

- SearchableSelect에 서버 검색 모드 구현 (onSearch 콜백 + 디바운스 300ms)
- 최소 입력 조건: 한글 완성형 1자 또는 영문/숫자 2자 이상
- 조건 미충족 시 안내 메시지 표시, 옵션 목록 숨김
- ReceivingDetail에서 handleItemSearch/handleSupplierSearch로 API 호출 연동
- 초기 전체 로드 제거, 검색 시에만 API 호출
This commit is contained in:
2026-01-29 11:32:18 +09:00
parent 3e6cdb5ed5
commit 3dab72701e
2 changed files with 101 additions and 22 deletions

View File

@@ -6,10 +6,12 @@
* Popover + Command 패턴으로 구현
* - 검색 가능하지만 직접 입력은 불가 (선택만 허용)
* - 선택 후 Popover 자동 닫힘
* - 서버 검색 모드: onSearch 콜백으로 API 연동 (디바운스 300ms)
* - 최소 입력 조건: 한글 1자(완성형) / 영문 2자
*/
import * as React from 'react';
import { Check, ChevronsUpDown, Search } from 'lucide-react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from './utils';
import { Button } from './button';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
@@ -37,10 +39,31 @@ interface SearchableSelectProps {
emptyText?: string;
disabled?: boolean;
className?: string;
/** 서버 검색 모드: 검색어 변경 시 호출 */
/** 서버 검색 모드: 검색어 변경 시 호출 (디바운스 적용) */
onSearch?: (query: string) => void;
/** 로딩 상태 */
isLoading?: boolean;
/** 최소 입력 안내 메시지 */
minInputText?: string;
}
/**
* 검색어가 최소 입력 조건을 만족하는지 확인
* - 한글 완성형 1자 이상 또는 영문 2자 이상
*/
function isValidSearchQuery(query: string): boolean {
if (!query || !query.trim()) return false;
const trimmed = query.trim();
// 한글 완성형 1자 이상
const hasCompleteKorean = /[가-힣]/.test(trimmed);
if (hasCompleteKorean) return true;
// 영문 2자 이상
const englishChars = trimmed.replace(/[^a-zA-Z]/g, '');
if (englishChars.length >= 2) return true;
// 숫자+영문 조합 2자 이상
const alphanumeric = trimmed.replace(/[^a-zA-Z0-9]/g, '');
if (alphanumeric.length >= 2) return true;
return false;
}
export function SearchableSelect({
@@ -54,11 +77,15 @@ export function SearchableSelect({
className,
onSearch,
isLoading = false,
minInputText = '한글 1자 또는 영문 2자 이상 입력하세요',
}: SearchableSelectProps) {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedOption = options.find((opt) => opt.value === value);
const isServerSearch = !!onSearch;
const queryValid = isValidSearchQuery(searchQuery);
const handleSelect = (selectedValue: string) => {
const option = options.find((opt) => opt.value === selectedValue);
@@ -71,7 +98,21 @@ export function SearchableSelect({
const handleSearchChange = (query: string) => {
setSearchQuery(query);
onSearch?.(query);
if (!isServerSearch) return;
// 디바운스 취소
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// 최소 입력 조건 미충족 시 검색하지 않음
if (!isValidSearchQuery(query)) return;
// 디바운스 300ms
debounceRef.current = setTimeout(() => {
onSearch(query);
}, 300);
};
// Popover 닫힐 때 검색어 초기화
@@ -79,9 +120,33 @@ export function SearchableSelect({
setOpen(isOpen);
if (!isOpen) {
setSearchQuery('');
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}
};
// 클린업
React.useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
// 서버 검색 모드에서 안내 메시지 결정
const getEmptyMessage = () => {
if (isServerSearch) {
if (!searchQuery) return minInputText;
if (!queryValid) return minInputText;
}
return emptyText;
};
// 서버 검색 모드에서 검색어 미입력/미충족 시 옵션 숨김
const displayOptions = isServerSearch && !queryValid ? [] : options;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
@@ -103,7 +168,7 @@ export function SearchableSelect({
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command shouldFilter={!onSearch}>
<Command shouldFilter={!isServerSearch}>
<CommandInput
placeholder={searchPlaceholder}
value={searchQuery}
@@ -112,14 +177,14 @@ export function SearchableSelect({
<CommandList>
{isLoading ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
<Search className="mr-2 h-4 w-4 animate-pulse" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</div>
) : (
<>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandEmpty>{getEmptyMessage()}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
{displayOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
@@ -150,4 +215,4 @@ export function SearchableSelect({
</PopoverContent>
</Popover>
);
}
}