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