diff --git a/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx index 5fec58a9..d88cda44 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -90,23 +90,33 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { // 수입검사 성적서 모달 상태 const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); - // 품목/발주처 검색 옵션 + // 품목/발주처 검색 옵션 및 로딩 상태 const [itemOptions, setItemOptions] = useState([]); const [supplierOptions, setSupplierOptions] = useState([]); + const [isItemSearching, setIsItemSearching] = useState(false); + const [isSupplierSearching, setIsSupplierSearching] = useState(false); - // 품목/발주처 옵션 초기 로드 - useEffect(() => { - if (!isNewMode && !isEditMode) return; - const loadOptions = async () => { - const [itemsResult, suppliersResult] = await Promise.all([ - searchItems(), - searchSuppliers(), - ]); - if (itemsResult.success) setItemOptions(itemsResult.data); - if (suppliersResult.success) setSupplierOptions(suppliersResult.data); - }; - loadOptions(); - }, [isNewMode, isEditMode]); + // 품목 검색 핸들러 + const handleItemSearch = useCallback(async (query: string) => { + setIsItemSearching(true); + try { + const result = await searchItems(query); + if (result.success) setItemOptions(result.data); + } finally { + setIsItemSearching(false); + } + }, []); + + // 발주처 검색 핸들러 + const handleSupplierSearch = useCallback(async (query: string) => { + setIsSupplierSearching(true); + try { + const result = await searchSuppliers(query); + if (result.success) setSupplierOptions(result.data); + } finally { + setIsSupplierSearching(false); + } + }, []); // API 데이터 로드 const loadData = useCallback(async () => { @@ -322,6 +332,8 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { unit: found?.unit ?? prev.unit ?? 'EA', })); }} + onSearch={handleItemSearch} + isLoading={isItemSearching} placeholder="품목코드 검색 및 선택" searchPlaceholder="품목코드 또는 품목명 검색..." emptyText="검색 결과가 없습니다" @@ -348,6 +360,8 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { options={supplierOptions} value={formData.supplier || ''} onChange={(value) => handleInputChange('supplier', value)} + onSearch={handleSupplierSearch} + isLoading={isSupplierSearching} placeholder="발주처 검색 및 선택" searchPlaceholder="발주처명 검색..." emptyText="검색 결과가 없습니다" diff --git a/src/components/ui/searchable-select.tsx b/src/components/ui/searchable-select.tsx index 4d48e815..ff62b821 100644 --- a/src/components/ui/searchable-select.tsx +++ b/src/components/ui/searchable-select.tsx @@ -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 | 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 ( @@ -103,7 +168,7 @@ export function SearchableSelect({ - + {isLoading ? (
- + 검색 중...
) : ( <> - {emptyText} + {getEmptyMessage()} - {options.map((option) => ( + {displayOptions.map((option) => (
); -} \ No newline at end of file +}