summaryrefslogtreecommitdiff
path: root/components/common
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-07-28 12:10:39 +0000
committerjoonhoekim <26rote@gmail.com>2025-07-28 12:10:39 +0000
commit75249e6fa46864f49d4eb91bd755171b6b65eaae (patch)
treef2c021f0fe10b3513d29f05ca15b82e460d79d20 /components/common
parentc228a89c2834ee63b209bad608837c39643f350e (diff)
(김준회) 공통모듈 - Knox 결재 모듈 구현, 유저 선택기 구현, 상신 결재 저장을 위한 DB 스키마 및 서비스 추가, spreadjs 라이센스 환경변수 통일, 유저 테이블에 epId 컬럼 추가
Diffstat (limited to 'components/common')
-rw-r--r--components/common/user/user-selector.tsx447
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>
+ )
+}