summaryrefslogtreecommitdiff
path: root/components/common/vendor/vendor-selector.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/common/vendor/vendor-selector.tsx')
-rw-r--r--components/common/vendor/vendor-selector.tsx415
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>
+ )
+}