diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-22 18:59:13 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-22 18:59:13 +0900 |
| commit | ba35e67845f935c8ce0151c9ef1fefa0b0510faf (patch) | |
| tree | d05eb27fab2acc54a839b2590c89e860d58fb747 /components/common/vendor/vendor-selector.tsx | |
| parent | e4bd037d158513e45373ad9e1ef13f71af12162a (diff) | |
(김준회) AVL 피드백 반영 (이진용 프로 건)
Diffstat (limited to 'components/common/vendor/vendor-selector.tsx')
| -rw-r--r-- | components/common/vendor/vendor-selector.tsx | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/components/common/vendor/vendor-selector.tsx b/components/common/vendor/vendor-selector.tsx new file mode 100644 index 00000000..aa79943a --- /dev/null +++ b/components/common/vendor/vendor-selector.tsx @@ -0,0 +1,415 @@ +"use client" + +import React, { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, X, Search, ChevronLeft, ChevronRight } from "lucide-react" +import { cn } from "@/lib/utils" +import { useDebounce } from "@/hooks/use-debounce" +import { searchVendorsForSelector, VendorSearchItem } from "./vendor-service" + +interface VendorSelectorProps { + selectedVendors?: VendorSearchItem[] + onVendorsChange?: (vendors: VendorSearchItem[]) => void + singleSelect?: boolean + placeholder?: string + noValuePlaceHolder?: string + disabled?: boolean + className?: string + closeOnSelect?: boolean + excludeVendorIds?: Set<number> // 제외할 벤더 ID들 + showInitialData?: boolean // 초기 클릭시 벤더들을 로드할지 여부 + maxSelections?: number // 최대 선택 가능한 벤더 개수 + statusFilter?: string // 특정 상태의 벤더만 표시 +} + +export function VendorSelector({ + selectedVendors = [], + onVendorsChange, + singleSelect = false, + placeholder = "벤더를 검색하세요...", + noValuePlaceHolder = "벤더를 검색해주세요", + disabled = false, + className, + closeOnSelect = true, + excludeVendorIds, + showInitialData = true, + maxSelections, + statusFilter +}: VendorSelectorProps) { + + const [open, setOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [searchResults, setSearchResults] = useState<VendorSearchItem[]>([]) + const [isSearching, setIsSearching] = useState(false) + const [searchError, setSearchError] = useState<string | null>(null) + const [currentPage, setCurrentPage] = useState(1) + const [initialDataLoaded, setInitialDataLoaded] = useState(false) + const [pagination, setPagination] = useState({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300) + + // 검색 실행 - useCallback으로 메모이제이션 + const performSearch = useCallback(async (query: string, page: number = 1) => { + setIsSearching(true) + setSearchError(null) + + try { + const result = await searchVendorsForSelector(query, page, 10, { + statusFilter, + sortBy: 'vendorName', + sortOrder: 'asc' + }) + + if (result.success) { + setSearchResults(result.data) + setPagination(result.pagination) + setCurrentPage(page) + } else { + setSearchResults([]) + setSearchError("검색 중 오류가 발생했습니다.") + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + } + } catch (err) { + console.error("벤더 검색 실패:", err) + setSearchResults([]) + setSearchError("검색 중 오류가 발생했습니다.") + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + } finally { + setIsSearching(false) + } + }, [statusFilter]) + + // Popover 열림시 초기 데이터 로드 + React.useEffect(() => { + if (open && showInitialData && !initialDataLoaded && !searchQuery.trim()) { + setInitialDataLoaded(true) + performSearch("", 1) // 빈 쿼리로 초기 데이터 로드 + } + }, [open, showInitialData, initialDataLoaded, searchQuery, performSearch]) + + // Debounced 검색어 변경 시 검색 실행 (검색어가 있을 때만) + React.useEffect(() => { + if (debouncedSearchQuery.trim()) { + setCurrentPage(1) + performSearch(debouncedSearchQuery, 1) + } else if (showInitialData && initialDataLoaded) { + // 검색어가 없고 초기 데이터를 보여주는 경우 초기 데이터 유지 + // 아무것도 하지 않음 (기존 데이터 유지) + } else { + // 검색어가 없으면 결과 초기화 + setSearchResults([]) + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + } + }, [debouncedSearchQuery, performSearch, showInitialData, initialDataLoaded]) + + // 페이지 변경 처리 - useCallback으로 메모이제이션 + const handlePageChange = useCallback((newPage: number) => { + if (newPage >= 1 && newPage <= pagination.pageCount) { + const query = debouncedSearchQuery.trim() || (showInitialData && initialDataLoaded ? "" : debouncedSearchQuery) + performSearch(query, newPage) + } + }, [pagination.pageCount, performSearch, debouncedSearchQuery, showInitialData, initialDataLoaded]) + + // 벤더 선택 처리 - useCallback으로 메모이제이션 + const handleVendorSelect = useCallback((vendor: VendorSearchItem) => { + if (disabled) return + + let newSelectedVendors: VendorSearchItem[] + + // maxSelections가 1이면 단일 선택 모드로 동작 + const isSingleSelectMode = singleSelect || maxSelections === 1 + + if (isSingleSelectMode) { + newSelectedVendors = [vendor] + } else { + const isAlreadySelected = selectedVendors.some( + (selected) => selected.id === vendor.id + ) + + if (isAlreadySelected) { + newSelectedVendors = selectedVendors.filter( + (selected) => selected.id !== vendor.id + ) + } else { + // 최대 선택 개수 확인 + if (maxSelections && selectedVendors.length >= maxSelections) { + // 최대 개수에 도달한 경우 선택하지 않음 + return + } + newSelectedVendors = [...selectedVendors, vendor] + } + } + + onVendorsChange?.(newSelectedVendors) + + if (closeOnSelect && isSingleSelectMode) { + setOpen(false) + } + }, [disabled, singleSelect, maxSelections, selectedVendors, onVendorsChange, closeOnSelect]) + + // 개별 벤더 제거 + const handleRemoveVendor = useCallback((vendorToRemove: VendorSearchItem) => { + if (disabled) return + + const newSelectedVendors = selectedVendors.filter( + (vendor) => vendor.id !== vendorToRemove.id + ) + onVendorsChange?.(newSelectedVendors) + }, [disabled, selectedVendors, onVendorsChange]) + + // 선택된 벤더가 있는지 확인 + const isVendorSelected = useCallback((vendor: VendorSearchItem) => { + return selectedVendors.some((selected) => selected.id === vendor.id) + }, [selectedVendors]) + + // 벤더 상태별 색상 + const getStatusColor = (status: string) => { + switch (status) { + case 'ACTIVE': return 'bg-green-100 text-green-800' + case 'APPROVED': return 'bg-blue-100 text-blue-800' + case 'PENDING_REVIEW': return 'bg-yellow-100 text-yellow-800' + case 'INACTIVE': return 'bg-gray-100 text-gray-800' + default: return 'bg-gray-100 text-gray-800' + } + } + + return ( + <div className={cn("w-full", className)}> + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between min-h-[2.5rem] h-auto" + disabled={disabled} + > + <div className="flex flex-wrap gap-1 flex-1 text-left"> + {selectedVendors.length === 0 ? ( + <span className="text-muted-foreground">{noValuePlaceHolder}</span> + ) : ( + selectedVendors.map((vendor) => ( + <Badge + key={vendor.id} + variant="secondary" + className="gap-1 pr-1" + > + <span className=""> + {vendor.displayText} + </span> + {!disabled && ( + <button + type="button" + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + handleRemoveVendor(vendor) + }} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + )) + )} + </div> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0" align="start"> + <Command> + <div className="flex items-center border-b px-3"> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <Input + placeholder={placeholder} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none border-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50" + /> + </div> + + {/* 스크롤 컨테이너 + 고정 페이지네이션 */} + <div className="max-h-[50vh] flex flex-col"> + <CommandList + className="flex-1 overflow-y-auto overflow-x-hidden min-h-0" + // shadcn CommandList 버그 처리 - 스크롤 이벤트 전파 차단 + onWheel={(e) => { + e.stopPropagation() // 이벤트 전파 차단 + const target = e.currentTarget + target.scrollTop += e.deltaY // 직접 스크롤 처리 + }} + > + {!searchQuery.trim() && !showInitialData ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 벤더를 검색하려면 검색어를 입력해주세요. + </div> + ) : !searchQuery.trim() && showInitialData && !initialDataLoaded ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 벤더 목록을 로드하려면 클릭해주세요. + </div> + ) : isSearching ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 검색 중... + </div> + ) : searchError ? ( + <div className="p-4 text-center text-sm text-red-500"> + {searchError} + </div> + ) : searchResults.length === 0 ? ( + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + ) : ( + <CommandGroup className="overflow-visible"> + {searchResults.map((vendor) => { + const isExcluded = excludeVendorIds?.has(vendor.id) + const isSelected = isVendorSelected(vendor) + const isMaxReached = maxSelections && selectedVendors.length >= maxSelections && !isSelected + const isDisabled = isExcluded || isMaxReached + + return ( + <CommandItem + key={vendor.id} + onSelect={() => { + if (!isDisabled) { + handleVendorSelect(vendor) + } + }} + className={cn( + "cursor-pointer", + isDisabled && "opacity-50 cursor-not-allowed bg-muted" + )} + > + <div className="mr-2 h-4 w-4 flex items-center justify-center"> + {isExcluded ? ( + <span className="text-xs text-muted-foreground">✓</span> + ) : isMaxReached ? ( + <span className="text-xs text-muted-foreground">-</span> + ) : ( + <Check + className={cn( + "h-4 w-4", + isSelected ? "opacity-100" : "opacity-0" + )} + /> + )} + </div> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className={cn( + "font-medium", + isDisabled && "text-muted-foreground" + )}> + {vendor.vendorName} + {isExcluded && ( + <span className="ml-2 text-xs bg-red-100 text-red-600 px-2 py-1 rounded"> + 제외됨 + </span> + )} + {isMaxReached && ( + <span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"> + 선택 제한 ({maxSelections}개) + </span> + )} + </div> + <Badge className={getStatusColor(vendor.status)}> + {vendor.status} + </Badge> + </div> + <div className="text-xs text-muted-foreground"> + {vendor.vendorCode ? ( + <span>벤더코드: {vendor.vendorCode}</span> + ) : ( + <span>벤더코드: -</span> + )} + </div> + </div> + </CommandItem> + ) + })} + </CommandGroup> + )} + </CommandList> + + {/* 고정 페이지네이션 - 항상 밑에 표시 */} + {searchResults.length > 0 && pagination.pageCount > 1 && ( + <div className="flex items-center justify-between border-t px-3 py-2 flex-shrink-0"> + <div className="text-xs text-muted-foreground"> + 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}- + {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시 + </div> + <div className="flex items-center gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage} + className="h-6 w-6 p-0" + > + <ChevronLeft className="h-3 w-3" /> + </Button> + <span className="text-xs"> + {pagination.page} / {pagination.pageCount} + </span> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage} + className="h-6 w-6 p-0" + > + <ChevronRight className="h-3 w-3" /> + </Button> + </div> + </div> + )} + </div> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} |
