fix(입고등록): 품목코드/발주처 검색 API 연동 및 최소 입력 조건 추가
- SearchableSelect에 서버 검색 모드 구현 (onSearch 콜백 + 디바운스 300ms) - 최소 입력 조건: 한글 완성형 1자 또는 영문/숫자 2자 이상 - 조건 미충족 시 안내 메시지 표시, 옵션 목록 숨김 - ReceivingDetail에서 handleItemSearch/handleSupplierSearch로 API 호출 연동 - 초기 전체 로드 제거, 검색 시에만 API 호출
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user