"use client" import * as React from "react" import { Search, X, Users, ChevronLeft, ChevronRight } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { useDebounce } from "@/hooks/use-debounce" import { cn } from "@/lib/utils" // import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Skeleton } from "@/components/ui/skeleton" import { searchUsersForSelector } from "@/lib/users/service" // User 타입 정의 export interface UserSelectItem { id: number name: string email: string epId?: string | null deptCode?: string | null deptName?: string | null imageUrl?: string | null domain?: string companyName?: string | null } // Domain 필터 타입 export type UserDomainFilter = | { type: "exclude"; domains: string[] } // partners가 아닌 경우 | { type: "include"; domains: string[] } // 특정 domain인 경우 | null // 필터 없음 // 페이지네이션 정보 타입 interface PaginationInfo { page: number perPage: number total: number pageCount: number hasNextPage: boolean hasPrevPage: boolean } export interface UserSelectorProps { /** 선택된 사용자들 */ selectedUsers?: UserSelectItem[] /** 사용자 선택 변경 콜백 */ onUsersChange?: (users: UserSelectItem[]) => void /** 단일 선택 모드 여부 */ singleSelect?: boolean /** domain 필터 */ domainFilter?: UserDomainFilter /** placeholder 텍스트 */ placeholder?: string /** 입력 없이 focus 시 표시할 placeholder */ noValuePlaceHolder?: string /** 비활성화 여부 */ disabled?: boolean /** 최대 선택 가능 사용자 수 */ maxSelections?: number /** 조직도 선택 다이얼로그 오픈 콜백 (추후 구현) */ onOpenOrgChart?: () => void /** 컴포넌트 클래스명 */ className?: string /** 사용자 선택 후 팝오버 닫기 여부 */ closeOnSelect?: boolean } export function UserSelector({ selectedUsers = [], onUsersChange, singleSelect = false, domainFilter, placeholder = "사용자를 검색하세요...", noValuePlaceHolder = "사용자를 검색하거나 조직도에서 찾아보세요", disabled = false, maxSelections, onOpenOrgChart, className, closeOnSelect = true // 기본값으로 선택 후 닫기 }: UserSelectorProps) { const [searchQuery, setSearchQuery] = React.useState("") const [isSearching, setIsSearching] = React.useState(false) const [searchResults, setSearchResults] = React.useState([]) const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) const [currentPage, setCurrentPage] = React.useState(1) const [pagination, setPagination] = React.useState({ page: 1, perPage: 10, total: 0, pageCount: 0, hasNextPage: false, hasPrevPage: false, }) const [searchError, setSearchError] = React.useState(null) const inputRef = React.useRef(null) // Debounce 적용된 검색어 const debouncedSearchQuery = useDebounce(searchQuery, 300) // 검색 실행 - useCallback으로 메모이제이션 const performSearch = React.useCallback(async (query: string, page: number = 1) => { setIsSearching(true) setSearchError(null) try { const result = await searchUsersForSelector(query, page, 10, domainFilter) 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) } }, [domainFilter]) // Debounced 검색어 변경 시 검색 실행 React.useEffect(() => { setCurrentPage(1) performSearch(debouncedSearchQuery, 1) }, [debouncedSearchQuery, performSearch]) // 페이지 변경 처리 - useCallback으로 메모이제이션 const handlePageChange = React.useCallback((newPage: number) => { if (newPage >= 1 && newPage <= pagination.pageCount) { performSearch(debouncedSearchQuery, newPage) } }, [pagination.pageCount, performSearch, debouncedSearchQuery]) // 사용자 선택 처리 - useCallback으로 메모이제이션 const handleUserSelect = React.useCallback((user: UserSelectItem) => { if (disabled) return const isSelected = selectedUsers.some(u => u.id === user.id) let newSelection: UserSelectItem[] if (singleSelect) { newSelection = isSelected ? [] : [user] } else { if (isSelected) { newSelection = selectedUsers.filter(u => u.id !== user.id) } else { if (maxSelections && selectedUsers.length >= maxSelections) { return // 최대 선택 수 도달 } newSelection = [...selectedUsers, user] } } onUsersChange?.(newSelection) // 선택 후 팝오버 닫기 (closeOnSelect가 true이거나 단일 선택 모드일 때) if ((closeOnSelect || singleSelect) && !isSelected) { setIsPopoverOpen(false) setSearchQuery("") } }, [disabled, selectedUsers, singleSelect, maxSelections, onUsersChange, closeOnSelect]) // 선택된 사용자 제거 - useCallback으로 메모이제이션 const handleRemoveUser = React.useCallback((userId: number) => { if (disabled) return const newSelection = selectedUsers.filter(u => u.id !== userId) onUsersChange?.(newSelection) }, [disabled, selectedUsers, onUsersChange]) // 사용자 이니셜 생성 - useMemo로 메모이제이션 const getUserInitials = React.useCallback((name: string) => { const names = name.split(' ') return names.length > 1 ? `${names[0][0]}${names[1][0]}` : name.slice(0, 2) }, []) // Input 이벤트 핸들러들 const handleInputFocus = React.useCallback(() => { setIsPopoverOpen(true) }, []) const handleInputChange = React.useCallback((e: React.ChangeEvent) => { setSearchQuery(e.target.value) if (!isPopoverOpen) { setIsPopoverOpen(true) } }, [isPopoverOpen]) const handleClosePopover = React.useCallback(() => { setIsPopoverOpen(false) }, []) // 계산된 값들 - useMemo로 메모이제이션 const shouldShowResults = React.useMemo(() => searchQuery.trim() || isSearching, [searchQuery, isSearching]) const hasResults = React.useMemo(() => searchResults.length > 0, [searchResults.length]) // 검색 결과 렌더링 - 컴포넌트 분리로 가독성 향상 const renderSearchResults = React.useMemo(() => { if (!shouldShowResults) { return (
{noValuePlaceHolder}
) } if (isSearching) { return (
{Array.from({ length: 3 }).map((_, i) => (
?
))}
) } if (searchError) { return (
{searchError}
) } if (!hasResults) { return (
검색 결과가 없습니다.
) } return ( <>
{searchResults.map((user) => { const isSelected = selectedUsers.some(u => u.id === user.id) const canSelect = !maxSelections || selectedUsers.length < maxSelections || isSelected return (
handleUserSelect(user)} className={cn( "flex items-center space-x-3 p-2 rounded-md cursor-pointer hover:bg-accent", !canSelect && !isSelected && "opacity-50 cursor-not-allowed" )} >
{getUserInitials(user.name)}
{user.name} · {user.email} {user.deptName && ( <> · {user.deptName} )} {isSelected &&
}
) })}
{/* 페이지네이션 */} {pagination.pageCount > 1 && (
{pagination.total}명 중 {((pagination.page - 1) * pagination.perPage) + 1}-{Math.min(pagination.page * pagination.perPage, pagination.total)}명
{pagination.page} / {pagination.pageCount}
)} ) }, [ shouldShowResults, noValuePlaceHolder, isSearching, searchError, hasResults, searchResults, selectedUsers, maxSelections, handleUserSelect, getUserInitials, pagination, currentPage, handlePageChange ]) return (
{/* 검색 입력 영역 */}
{/* 검색 결과 팝오버 */} {isPopoverOpen && (
{/* 팝오버 헤더 */}
사용자 선택
{/* 검색 결과 영역 */}
{renderSearchResults}
)}
{/* 조직도 찾기 버튼 */} {onOpenOrgChart && ( )}
{/* 선택된 사용자들 표시 */} {selectedUsers.length > 0 && (
{selectedUsers.map((user) => (
{getUserInitials(user.name)}
{user.name} {user.deptName && ( ({user.deptName}) )} {!disabled && ( )}
))}
)} {/* 선택 제한 안내 */} {maxSelections && selectedUsers.length >= maxSelections && (
최대 {maxSelections}명까지 선택할 수 있습니다.
)}
) }