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 | |
| parent | e4bd037d158513e45373ad9e1ef13f71af12162a (diff) | |
(김준회) AVL 피드백 반영 (이진용 프로 건)
Diffstat (limited to 'components/common/vendor')
| -rw-r--r-- | components/common/vendor/index.ts | 19 | ||||
| -rw-r--r-- | components/common/vendor/vendor-selector-dialog-multi.tsx | 320 | ||||
| -rw-r--r-- | components/common/vendor/vendor-selector-dialog-single.tsx | 215 | ||||
| -rw-r--r-- | components/common/vendor/vendor-selector.tsx | 415 | ||||
| -rw-r--r-- | components/common/vendor/vendor-service.ts | 263 |
5 files changed, 1232 insertions, 0 deletions
diff --git a/components/common/vendor/index.ts b/components/common/vendor/index.ts new file mode 100644 index 00000000..6e4a5a80 --- /dev/null +++ b/components/common/vendor/index.ts @@ -0,0 +1,19 @@ +// 벤더 선택기 컴포넌트들 및 관련 타입 export + +// 서비스 및 타입 +export { + searchVendorsForSelector, + getAllVendors, + getVendorById, + getVendorStatuses, + type VendorSearchItem, + type VendorSearchOptions, + type VendorPagination +} from './vendor-service' + +// 기본 선택기 컴포넌트 +export { VendorSelector } from './vendor-selector' + +// 다이얼로그 컴포넌트들 +export { VendorSelectorDialogSingle } from './vendor-selector-dialog-single' +export { VendorSelectorDialogMulti } from './vendor-selector-dialog-multi' diff --git a/components/common/vendor/vendor-selector-dialog-multi.tsx b/components/common/vendor/vendor-selector-dialog-multi.tsx new file mode 100644 index 00000000..32c8fa54 --- /dev/null +++ b/components/common/vendor/vendor-selector-dialog-multi.tsx @@ -0,0 +1,320 @@ +"use client" + +import React, { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { VendorSelector } from "./vendor-selector" +import { VendorSearchItem } from "./vendor-service" +import { X } from "lucide-react" + +/** + * 벤더 다중 선택 Dialog 컴포넌트 + * + * @description + * - VendorSelector를 Dialog로 래핑한 다중 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 여러 벤더를 선택한 후 확인 버튼으로 완료 + * - 최대 선택 개수 제한 가능 + * + * @VendorSearchItem_Structure + * 상태에서 관리되는 벤더 객체의 형태: + * ```typescript + * interface VendorSearchItem { + * id: number; // 벤더 ID + * vendorName: string; // 벤더명 + * vendorCode: string | null; // 벤더코드 (없을 수 있음) + * status: string; // 벤더 상태 (ACTIVE, PENDING_REVIEW 등) + * displayText: string; // 표시용 텍스트 (vendorName + vendorCode) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedVendors: 현재 선택된 벤더들 (배열) + * - tempSelectedVendors: Dialog 내에서 임시로 선택된 벤더들 (확인 버튼 클릭 전까지) + * + * @callback + * - onVendorsSelect: 벤더 선택 완료 시 호출되는 콜백 + * - 매개변수: VendorSearchItem[] + * - 선택된 벤더들의 배열 (빈 배열일 수도 있음) + * + * @usage + * ```tsx + * <VendorSelectorDialogMulti + * triggerLabel="벤더 선택 (다중)" + * selectedVendors={selectedVendors} + * onVendorsSelect={(vendors) => { + * setSelectedVendors(vendors); + * console.log('선택된 벤더들:', vendors); + * }} + * maxSelections={5} + * placeholder="벤더를 검색하세요..." + * /> + * ``` + */ + +interface VendorSelectorDialogMultiProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string + /** 현재 선택된 벤더들 */ + selectedVendors?: VendorSearchItem[] + /** 벤더 선택 완료 시 호출되는 콜백 */ + onVendorsSelect?: (vendors: VendorSearchItem[]) => void + /** 최대 선택 가능한 벤더 개수 */ + maxSelections?: number + /** 검색 입력창 placeholder */ + placeholder?: string + /** Dialog 제목 */ + title?: string + /** Dialog 설명 */ + description?: string + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" + /** 제외할 벤더 ID들 */ + excludeVendorIds?: Set<number> + /** 초기 데이터 표시 여부 */ + showInitialData?: boolean + /** 트리거 버튼에서 선택된 벤더들을 표시할지 여부 */ + showSelectedInTrigger?: boolean + /** 벤더 상태 필터 */ + statusFilter?: string +} + +export function VendorSelectorDialogMulti({ + triggerLabel = "벤더 선택", + selectedVendors = [], + onVendorsSelect, + maxSelections, + placeholder = "벤더를 검색하세요...", + title = "벤더 선택 (다중)", + description, + disabled = false, + triggerVariant = "outline", + excludeVendorIds, + showInitialData = true, + showSelectedInTrigger = true, + statusFilter +}: VendorSelectorDialogMultiProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false) + + // Dialog 내에서 임시로 선택된 벤더들 (확인 버튼 클릭 전까지) + const [tempSelectedVendors, setTempSelectedVendors] = useState<VendorSearchItem[]>([]) + + // Dialog 설명 동적 생성 + const dialogDescription = description || + (maxSelections + ? `원하는 벤더를 검색하고 선택해주세요. (최대 ${maxSelections}개)` + : "원하는 벤더를 검색하고 선택해주세요." + ) + + // Dialog 열림 시 현재 선택된 벤더들로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen) { + setTempSelectedVendors([...selectedVendors]) + } + }, [selectedVendors]) + + // 벤더 선택 처리 (Dialog 내에서) + const handleVendorsChange = useCallback((vendors: VendorSearchItem[]) => { + setTempSelectedVendors(vendors) + }, []) + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onVendorsSelect?.(tempSelectedVendors) + setOpen(false) + }, [tempSelectedVendors, onVendorsSelect]) + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedVendors([...selectedVendors]) + setOpen(false) + }, [selectedVendors]) + + // 전체 선택 해제 + const handleClearAll = useCallback(() => { + setTempSelectedVendors([]) + }, []) + + // 개별 벤더 제거 (트리거 버튼에서) + const handleRemoveVendor = useCallback((vendorToRemove: VendorSearchItem, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const newVendors = selectedVendors.filter( + (vendor) => vendor.id !== vendorToRemove.id + ) + onVendorsSelect?.(newVendors) + }, [selectedVendors, onVendorsSelect]) + + // 벤더 상태별 색상 + 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' + } + } + + // 트리거 버튼 렌더링 + const renderTriggerContent = () => { + if (selectedVendors.length === 0) { + return triggerLabel + } + + if (!showSelectedInTrigger) { + return `${triggerLabel} (${selectedVendors.length}개)` + } + + return ( + <div className="flex flex-wrap gap-1 items-center max-w-full"> + <span className="shrink-0">{triggerLabel}:</span> + {selectedVendors.slice(0, 2).map((vendor) => ( + <Badge + key={vendor.id} + variant="secondary" + className="gap-1 pr-1 max-w-[120px]" + > + <span className="truncate text-xs"> + {vendor.vendorName} + </span> + <Badge className={`${getStatusColor(vendor.status)} ml-1 text-xs`}> + {vendor.status} + </Badge> + {!disabled && ( + <button + type="button" + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + onClick={(e) => handleRemoveVendor(vendor, e)} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + ))} + {selectedVendors.length > 2 && ( + <Badge variant="outline" className="text-xs"> + +{selectedVendors.length - 2}개 + </Badge> + )} + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button + variant={triggerVariant} + disabled={disabled} + className="min-h-[2.5rem] h-auto justify-start" + > + {renderTriggerContent()} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{dialogDescription}</DialogDescription> + </DialogHeader> + + <div className="py-4"> + <VendorSelector + selectedVendors={tempSelectedVendors} + onVendorsChange={handleVendorsChange} + singleSelect={false} + maxSelections={maxSelections} + placeholder={placeholder} + noValuePlaceHolder="벤더를 선택해주세요" + closeOnSelect={false} + excludeVendorIds={excludeVendorIds} + showInitialData={showInitialData} + statusFilter={statusFilter} + /> + + {/* 선택된 벤더 개수 표시 */} + <div className="mt-2 text-sm text-muted-foreground"> + {maxSelections ? ( + `선택됨: ${tempSelectedVendors.length}/${maxSelections}개` + ) : ( + `선택됨: ${tempSelectedVendors.length}개` + )} + </div> + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedVendors.length > 0 && ( + <Button variant="ghost" onClick={handleClearAll}> + 전체 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 ({tempSelectedVendors.length}개) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedVendors, setSelectedVendors] = useState<VendorSearchItem[]>([]); + * + * return ( + * <VendorSelectorDialogMulti + * triggerLabel="벤더 선택" + * selectedVendors={selectedVendors} + * onVendorsSelect={(vendors) => { + * setSelectedVendors(vendors); + * console.log('선택된 벤더들:', vendors.map(v => ({ + * id: v.id, + * name: v.vendorName, + * code: v.vendorCode, + * status: v.status + * }))); + * }} + * maxSelections={5} + * title="협력업체 선택" + * description="프로젝트에 참여할 협력업체들을 선택해주세요." + * showSelectedInTrigger={true} + * statusFilter="ACTIVE" + * /> + * ); + * ``` + * + * @advanced_usage + * ```tsx + * // 제외할 벤더가 있는 경우 + * const excludedVendorIds = new Set([1, 2, 3]); + * + * <VendorSelectorDialogMulti + * selectedVendors={selectedVendors} + * onVendorsSelect={setSelectedVendors} + * excludeVendorIds={excludedVendorIds} + * maxSelections={3} + * triggerVariant="default" + * showSelectedInTrigger={false} + * statusFilter="APPROVED" + * /> + * ``` + */ diff --git a/components/common/vendor/vendor-selector-dialog-single.tsx b/components/common/vendor/vendor-selector-dialog-single.tsx new file mode 100644 index 00000000..da9a9a74 --- /dev/null +++ b/components/common/vendor/vendor-selector-dialog-single.tsx @@ -0,0 +1,215 @@ +"use client" + +import React, { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { VendorSelector } from "./vendor-selector" +import { VendorSearchItem } from "./vendor-service" + +/** + * 벤더 단일 선택 Dialog 컴포넌트 + * + * @description + * - VendorSelector를 Dialog로 래핑한 단일 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 벤더를 선택하면 Dialog가 닫히며 결과를 반환 + * + * @VendorSearchItem_Structure + * 상태에서 관리되는 벤더 객체의 형태: + * ```typescript + * interface VendorSearchItem { + * id: number; // 벤더 ID + * vendorName: string; // 벤더명 + * vendorCode: string | null; // 벤더코드 (없을 수 있음) + * status: string; // 벤더 상태 (ACTIVE, PENDING_REVIEW 등) + * displayText: string; // 표시용 텍스트 (vendorName + vendorCode) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedVendor: 현재 선택된 벤더 (단일) + * - tempSelectedVendor: Dialog 내에서 임시로 선택된 벤더 (확인 버튼 클릭 전까지) + * + * @callback + * - onVendorSelect: 벤더 선택 완료 시 호출되는 콜백 + * - 매개변수: VendorSearchItem | null + * - 선택된 벤더 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * <VendorSelectorDialogSingle + * triggerLabel="벤더 선택" + * selectedVendor={selectedVendor} + * onVendorSelect={(vendor) => { + * setSelectedVendor(vendor); + * console.log('선택된 벤더:', vendor); + * }} + * placeholder="벤더를 검색하세요..." + * /> + * ``` + */ + +interface VendorSelectorDialogSingleProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string + /** 현재 선택된 벤더 */ + selectedVendor?: VendorSearchItem | null + /** 벤더 선택 완료 시 호출되는 콜백 */ + onVendorSelect?: (vendor: VendorSearchItem | null) => void + /** 검색 입력창 placeholder */ + placeholder?: string + /** Dialog 제목 */ + title?: string + /** Dialog 설명 */ + description?: string + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" + /** 제외할 벤더 ID들 */ + excludeVendorIds?: Set<number> + /** 초기 데이터 표시 여부 */ + showInitialData?: boolean + /** 벤더 상태 필터 */ + statusFilter?: string +} + +export function VendorSelectorDialogSingle({ + triggerLabel = "벤더 선택", + selectedVendor = null, + onVendorSelect, + placeholder = "벤더를 검색하세요...", + title = "벤더 선택", + description = "원하는 벤더를 검색하고 선택해주세요.", + disabled = false, + triggerVariant = "outline", + excludeVendorIds, + showInitialData = true, + statusFilter +}: VendorSelectorDialogSingleProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false) + + // Dialog 내에서 임시로 선택된 벤더 (확인 버튼 클릭 전까지) + const [tempSelectedVendor, setTempSelectedVendor] = useState<VendorSearchItem | null>(null) + + // Dialog 열림 시 현재 선택된 벤더로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen) { + setTempSelectedVendor(selectedVendor || null) + } + }, [selectedVendor]) + + // 벤더 선택 처리 (Dialog 내에서) + const handleVendorChange = useCallback((vendors: VendorSearchItem[]) => { + setTempSelectedVendor(vendors.length > 0 ? vendors[0] : null) + }, []) + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onVendorSelect?.(tempSelectedVendor) + setOpen(false) + }, [tempSelectedVendor, onVendorSelect]) + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedVendor(selectedVendor || null) + setOpen(false) + }, [selectedVendor]) + + // 선택 해제 + const handleClear = useCallback(() => { + setTempSelectedVendor(null) + }, []) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant={triggerVariant} disabled={disabled}> + {selectedVendor ? ( + <span className="truncate"> + {selectedVendor.displayText} + </span> + ) : ( + triggerLabel + )} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + <div className="py-4"> + <VendorSelector + selectedVendors={tempSelectedVendor ? [tempSelectedVendor] : []} + onVendorsChange={handleVendorChange} + singleSelect={true} + placeholder={placeholder} + noValuePlaceHolder="벤더를 선택해주세요" + closeOnSelect={false} + excludeVendorIds={excludeVendorIds} + showInitialData={showInitialData} + statusFilter={statusFilter} + /> + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedVendor && ( + <Button variant="ghost" onClick={handleClear}> + 선택 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedVendor, setSelectedVendor] = useState<VendorSearchItem | null>(null); + * + * return ( + * <VendorSelectorDialogSingle + * triggerLabel="벤더 선택" + * selectedVendor={selectedVendor} + * onVendorSelect={(vendor) => { + * setSelectedVendor(vendor); + * if (vendor) { + * console.log('선택된 벤더:', { + * id: vendor.id, + * name: vendor.vendorName, + * code: vendor.vendorCode, + * status: vendor.status + * }); + * } else { + * console.log('벤더 선택이 해제되었습니다.'); + * } + * }} + * title="벤더 선택" + * description="협력업체를 검색하고 선택해주세요." + * statusFilter="ACTIVE" // ACTIVE 상태의 벤더만 표시 + * /> + * ); + * ``` + */ 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> + ) +} diff --git a/components/common/vendor/vendor-service.ts b/components/common/vendor/vendor-service.ts new file mode 100644 index 00000000..83a63cae --- /dev/null +++ b/components/common/vendor/vendor-service.ts @@ -0,0 +1,263 @@ +"use server" + +import db from '@/db/db' +import { vendors } from '@/db/schema/vendors' +import { eq, or, ilike, and, asc, desc } from 'drizzle-orm' + +// 벤더 타입 정의 +export interface VendorSearchItem { + id: number + vendorName: string + vendorCode: string | null + status: string + displayText: string // vendorName + vendorCode로 구성된 표시용 텍스트 +} + +// 벤더 검색 옵션 +export interface VendorSearchOptions { + searchTerm?: string + statusFilter?: string // 특정 상태로 필터링 + limit?: number + offset?: number + sortBy?: 'vendorName' | 'vendorCode' | 'status' + sortOrder?: 'asc' | 'desc' +} + +// 페이지네이션 정보 +export interface VendorPagination { + page: number + perPage: number + total: number + pageCount: number + hasNextPage: boolean + hasPrevPage: boolean +} + +/** + * 벤더 검색 (페이지네이션 지원) + * 벤더명, 벤더코드로 검색 가능 + */ +export async function searchVendorsForSelector( + searchTerm: string = "", + page: number = 1, + perPage: number = 10, + options: Omit<VendorSearchOptions, 'searchTerm' | 'limit' | 'offset'> = {} +): Promise<{ + success: boolean + data: VendorSearchItem[] + pagination: VendorPagination + error?: string +}> { + try { + const { statusFilter, sortBy = 'vendorName', sortOrder = 'asc' } = options + const offset = (page - 1) * perPage + + // WHERE 조건 구성 + let whereClause + + // 검색어 조건 + const searchCondition = searchTerm && searchTerm.trim() + ? or( + ilike(vendors.vendorName, `%${searchTerm.trim()}%`), + ilike(vendors.vendorCode, `%${searchTerm.trim()}%`) + ) + : undefined + + // 상태 필터 조건 - 타입 안전하게 처리 + const statusCondition = statusFilter + ? eq(vendors.status, statusFilter as string) + : undefined + + // 조건들을 결합 + if (searchCondition && statusCondition) { + whereClause = and(searchCondition, statusCondition) + } else if (searchCondition) { + whereClause = searchCondition + } else if (statusCondition) { + whereClause = statusCondition + } + + // 정렬 옵션 + const orderBy = sortOrder === 'desc' + ? desc(vendors[sortBy]) + : asc(vendors[sortBy]) + + // 전체 개수 조회 + let totalCountQuery = db + .select({ count: vendors.id }) + .from(vendors) + + if (whereClause) { + totalCountQuery = totalCountQuery.where(whereClause) + } + + const totalCountResult = await totalCountQuery + const total = totalCountResult.length + + // 데이터 조회 + let dataQuery = db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + + if (whereClause) { + dataQuery = dataQuery.where(whereClause) + } + + const result = await dataQuery + .orderBy(orderBy) + .limit(perPage) + .offset(offset) + + // displayText 추가 + const vendorItems: VendorSearchItem[] = result.map(vendor => ({ + ...vendor, + displayText: vendor.vendorCode + ? `${vendor.vendorName} (${vendor.vendorCode})` + : vendor.vendorName + })) + + // 페이지네이션 정보 계산 + const pageCount = Math.ceil(total / perPage) + const pagination: VendorPagination = { + page, + perPage, + total, + pageCount, + hasNextPage: page < pageCount, + hasPrevPage: page > 1, + } + + return { + success: true, + data: vendorItems, + pagination + } + } catch (error) { + console.error('Error searching vendors:', error) + return { + success: false, + data: [], + pagination: { + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }, + error: '벤더 검색 중 오류가 발생했습니다.' + } + } +} + +/** + * 모든 벤더 조회 (필터링 없음) + */ +export async function getAllVendors(): Promise<{ + success: boolean + data: VendorSearchItem[] + error?: string +}> { + try { + const result = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + .orderBy(asc(vendors.vendorName)) + + const vendorItems: VendorSearchItem[] = result.map(vendor => ({ + ...vendor, + displayText: vendor.vendorCode + ? `${vendor.vendorName} (${vendor.vendorCode})` + : vendor.vendorName + })) + + return { + success: true, + data: vendorItems + } + } catch (error) { + console.error('Error fetching all vendors:', error) + return { + success: false, + data: [], + error: '벤더 목록을 조회하는 중 오류가 발생했습니다.' + } + } +} + +/** + * 특정 벤더 조회 (ID로) + */ +export async function getVendorById(vendorId: number): Promise<VendorSearchItem | null> { + if (!vendorId) { + return null + } + + try { + const result = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1) + + if (result.length === 0) { + return null + } + + const vendor = result[0] + return { + ...vendor, + displayText: vendor.vendorCode + ? `${vendor.vendorName} (${vendor.vendorCode})` + : vendor.vendorName + } + } catch (error) { + console.error('Error fetching vendor by ID:', error) + return null + } +} + +/** + * 벤더 상태 목록 조회 + */ +export async function getVendorStatuses(): Promise<{ + success: boolean + data: string[] + error?: string +}> { + try { + const result = await db + .selectDistinct({ status: vendors.status }) + .from(vendors) + .orderBy(asc(vendors.status)) + + const statuses = result.map(row => row.status).filter(Boolean) + + return { + success: true, + data: statuses + } + } catch (error) { + console.error('Error fetching vendor statuses:', error) + return { + success: false, + data: [], + error: '벤더 상태 목록을 조회하는 중 오류가 발생했습니다.' + } + } +} |
