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

@@ -90,23 +90,33 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
// 수입검사 성적서 모달 상태
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
// 품목/발주처 검색 옵션
// 품목/발주처 검색 옵션 및 로딩 상태
const [itemOptions, setItemOptions] = useState<SearchableSelectOption[]>([]);
const [supplierOptions, setSupplierOptions] = useState<SearchableSelectOption[]>([]);
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="검색 결과가 없습니다"

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>
);
}
}