diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-28 12:10:39 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-28 12:10:39 +0000 |
| commit | 75249e6fa46864f49d4eb91bd755171b6b65eaae (patch) | |
| tree | f2c021f0fe10b3513d29f05ca15b82e460d79d20 /components/common/user | |
| parent | c228a89c2834ee63b209bad608837c39643f350e (diff) | |
(김준회) 공통모듈 - Knox 결재 모듈 구현, 유저 선택기 구현, 상신 결재 저장을 위한 DB 스키마 및 서비스 추가, spreadjs 라이센스 환경변수 통일, 유저 테이블에 epId 컬럼 추가
Diffstat (limited to 'components/common/user')
| -rw-r--r-- | components/common/user/user-selector.tsx | 447 |
1 files changed, 447 insertions, 0 deletions
diff --git a/components/common/user/user-selector.tsx b/components/common/user/user-selector.tsx new file mode 100644 index 00000000..4a43fa5e --- /dev/null +++ b/components/common/user/user-selector.tsx @@ -0,0 +1,447 @@ +"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<UserSelectItem[]>([]) + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) + const [currentPage, setCurrentPage] = React.useState(1) + const [pagination, setPagination] = React.useState<PaginationInfo>({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + const [searchError, setSearchError] = React.useState<string | null>(null) + + const inputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) => { + 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 ( + <div className="p-4 text-sm text-muted-foreground text-center"> + {noValuePlaceHolder} + </div> + ) + } + + if (isSearching) { + return ( + <div className="p-2 space-y-2"> + {Array.from({ length: 3 }).map((_, i) => ( + <div key={i} className="flex items-center space-x-2 p-2"> + <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs"> + ? + </div> + <div className="space-y-1 flex-1"> + <Skeleton className="h-3 w-full" /> + </div> + </div> + ))} + </div> + ) + } + + if (searchError) { + return ( + <div className="p-4 text-sm text-destructive text-center"> + {searchError} + </div> + ) + } + + if (!hasResults) { + return ( + <div className="p-4 text-sm text-muted-foreground text-center"> + 검색 결과가 없습니다. + </div> + ) + } + + return ( + <> + <div className="p-1"> + {searchResults.map((user) => { + const isSelected = selectedUsers.some(u => u.id === user.id) + const canSelect = !maxSelections || selectedUsers.length < maxSelections || isSelected + + return ( + <div + key={user.id} + onClick={() => 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" + )} + > + <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs"> + {getUserInitials(user.name)} + </div> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 text-sm"> + <span className="font-medium">{user.name}</span> + <span className="text-muted-foreground">·</span> + <span className="text-muted-foreground">{user.email}</span> + {user.deptName && ( + <> + <span className="text-muted-foreground">·</span> + <span className="text-muted-foreground">{user.deptName}</span> + </> + )} + {isSelected && <div className="h-2 w-2 bg-primary rounded-full ml-auto" />} + </div> + </div> + </div> + ) + })} + </div> + + {/* 페이지네이션 */} + {pagination.pageCount > 1 && ( + <div className="flex items-center justify-between p-3 border-t bg-muted/30"> + <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-1"> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage} + className="h-8 w-8 p-0" + > + <ChevronLeft className="h-4 w-4" /> + </Button> + <span className="text-xs text-muted-foreground px-2"> + {pagination.page} / {pagination.pageCount} + </span> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage} + className="h-8 w-8 p-0" + > + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + </div> + )} + </> + ) + }, [ + shouldShowResults, + noValuePlaceHolder, + isSearching, + searchError, + hasResults, + searchResults, + selectedUsers, + maxSelections, + handleUserSelect, + getUserInitials, + pagination, + currentPage, + handlePageChange + ]) + + return ( + <div className={cn("space-y-2", className)}> + {/* 검색 입력 영역 */} + <div className="flex gap-2"> + <div className="flex-1 relative"> + <div className="relative"> + <Input + ref={inputRef} + placeholder={placeholder} + value={searchQuery} + onChange={handleInputChange} + onFocus={handleInputFocus} + disabled={disabled} + className="pr-10" + /> + <Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + </div> + + {/* 검색 결과 팝오버 */} + {isPopoverOpen && ( + <div className="absolute top-full left-0 right-0 z-50 mt-1 rounded-md border bg-popover text-popover-foreground shadow-md outline-none"> + {/* 팝오버 헤더 */} + <div className="flex items-center justify-between p-3 border-b"> + <span className="text-sm font-medium">사용자 선택</span> + <Button + variant="ghost" + size="sm" + onClick={handleClosePopover} + className="h-6 w-6 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + + {/* 검색 결과 영역 */} + <div className="max-h-[400px] overflow-y-auto"> + {renderSearchResults} + </div> + </div> + )} + </div> + + {/* 조직도 찾기 버튼 */} + {onOpenOrgChart && ( + <Button + variant="outline" + onClick={onOpenOrgChart} + disabled={disabled} + className="px-3" + > + <Users className="h-4 w-4 mr-2" /> + 찾기 + </Button> + )} + </div> + + {/* 선택된 사용자들 표시 */} + {selectedUsers.length > 0 && ( + <div className="flex flex-wrap gap-2"> + {selectedUsers.map((user) => ( + <Badge + key={user.id} + variant="secondary" + className="flex items-center gap-2 px-3 py-1" + > + <div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center text-xs"> + {getUserInitials(user.name)} + </div> + <span className="text-sm">{user.name}</span> + {user.deptName && ( + <span className="text-xs text-muted-foreground">({user.deptName})</span> + )} + {!disabled && ( + <Button + variant="ghost" + size="sm" + className="h-4 w-4 p-0 hover:bg-transparent" + onClick={() => handleRemoveUser(user.id)} + > + <X className="h-3 w-3" /> + </Button> + )} + </Badge> + ))} + </div> + )} + + {/* 선택 제한 안내 */} + {maxSelections && selectedUsers.length >= maxSelections && ( + <div className="text-xs text-muted-foreground"> + 최대 {maxSelections}명까지 선택할 수 있습니다. + </div> + )} + </div> + ) +} |
