summaryrefslogtreecommitdiff
path: root/components/common/organization/organization-manager-selector.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/common/organization/organization-manager-selector.tsx')
-rw-r--r--components/common/organization/organization-manager-selector.tsx338
1 files changed, 338 insertions, 0 deletions
diff --git a/components/common/organization/organization-manager-selector.tsx b/components/common/organization/organization-manager-selector.tsx
new file mode 100644
index 00000000..c715b3a1
--- /dev/null
+++ b/components/common/organization/organization-manager-selector.tsx
@@ -0,0 +1,338 @@
+"use client"
+
+import * as React from "react"
+import { Search, X, Building2, 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 { Skeleton } from "@/components/ui/skeleton"
+import { searchOrganizationsForManager } from "@/lib/knox-api/organization-service"
+
+// 조직 관리자 타입 정의
+export interface OrganizationManagerItem {
+ id: string
+ departmentCode: string
+ departmentName: string
+ managerId: string
+ managerName: string
+ managerTitle: string
+ companyCode: string
+ companyName: string
+}
+
+// 페이지네이션 정보 타입
+interface PaginationInfo {
+ page: number
+ perPage: number
+ total: number
+ pageCount: number
+ hasNextPage: boolean
+ hasPrevPage: boolean
+}
+
+export interface OrganizationManagerSelectorProps {
+ /** 선택된 조직 관리자들 */
+ selectedManagers?: OrganizationManagerItem[]
+ /** 조직 관리자 선택 변경 콜백 */
+ onManagersChange?: (managers: OrganizationManagerItem[]) => void
+ /** 단일 선택 모드 여부 */
+ singleSelect?: boolean
+ /** placeholder 텍스트 */
+ placeholder?: string
+ /** 입력 없이 focus 시 표시할 placeholder */
+ noValuePlaceHolder?: string
+ /** 비활성화 여부 */
+ disabled?: boolean
+ /** 최대 선택 가능 조직 관리자 수 */
+ maxSelections?: number
+ /** 컴포넌트 클래스명 */
+ className?: string
+ /** 선택 후 팝오버 닫기 여부 */
+ closeOnSelect?: boolean
+}
+
+export function OrganizationManagerSelector({
+ selectedManagers = [],
+ onManagersChange,
+ singleSelect = false,
+ placeholder = "조직 관리자를 검색하세요...",
+ noValuePlaceHolder = "조직명 또는 관리자명으로 검색하세요",
+ disabled = false,
+ maxSelections,
+ className,
+ closeOnSelect = true
+}: OrganizationManagerSelectorProps) {
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [isSearching, setIsSearching] = React.useState(false)
+ const [searchResults, setSearchResults] = React.useState<OrganizationManagerItem[]>([])
+ 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)
+
+ // 검색 실행
+ const performSearch = React.useCallback(async (query: string, page: number = 1) => {
+ if (!query.trim()) {
+ setSearchResults([])
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ })
+ return
+ }
+
+ setIsSearching(true)
+ setSearchError(null)
+
+ try {
+ const result = await searchOrganizationsForManager({
+ search: query,
+ page,
+ perPage: 10,
+ })
+
+ setSearchResults(result.data)
+ setPagination({
+ page: result.pageCount,
+ perPage: 10,
+ total: result.total,
+ pageCount: result.pageCount,
+ hasNextPage: page < result.pageCount,
+ hasPrevPage: page > 1,
+ })
+ } catch (error) {
+ setSearchError("검색 중 오류가 발생했습니다.")
+ setSearchResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ }, [])
+
+ // 검색어 변경 시 검색 실행
+ React.useEffect(() => {
+ performSearch(debouncedSearchQuery, 1)
+ setCurrentPage(1)
+ }, [debouncedSearchQuery, performSearch])
+
+ // 페이지 변경 시 검색 실행
+ const handlePageChange = React.useCallback((newPage: number) => {
+ setCurrentPage(newPage)
+ performSearch(debouncedSearchQuery, newPage)
+ }, [debouncedSearchQuery, performSearch])
+
+ // 선택된 관리자 제거
+ const removeManager = React.useCallback((managerId: string) => {
+ const updated = selectedManagers.filter(m => m.id !== managerId)
+ onManagersChange?.(updated)
+ }, [selectedManagers, onManagersChange])
+
+ // 관리자 선택
+ const selectManager = React.useCallback((manager: OrganizationManagerItem) => {
+ if (singleSelect) {
+ onManagersChange?.([manager])
+ if (closeOnSelect) {
+ setIsPopoverOpen(false)
+ }
+ return
+ }
+
+ // 최대 선택 수 체크
+ if (maxSelections && selectedManagers.length >= maxSelections) {
+ return
+ }
+
+ // 이미 선택된 관리자인지 확인
+ const isAlreadySelected = selectedManagers.some(m => m.id === manager.id)
+ if (isAlreadySelected) {
+ return
+ }
+
+ const updated = [...selectedManagers, manager]
+ onManagersChange?.(updated)
+
+ if (closeOnSelect) {
+ setIsPopoverOpen(false)
+ }
+ }, [selectedManagers, onManagersChange, singleSelect, maxSelections, closeOnSelect])
+
+ // 전체 선택 해제
+ const clearAll = React.useCallback(() => {
+ onManagersChange?.([])
+ }, [onManagersChange])
+
+ return (
+ <div className={cn("w-full", className)}>
+ {/* 선택된 관리자들 표시 */}
+ {selectedManagers.length > 0 && (
+ <div className="mb-3 flex flex-wrap gap-2">
+ {selectedManagers.map((manager) => (
+ <Badge
+ key={manager.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ <Building2 className="w-3 h-3" />
+ <span className="text-xs">
+ {manager.departmentName} - {manager.managerName}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-0 ml-1 hover:bg-transparent"
+ onClick={() => removeManager(manager.id)}
+ >
+ <X className="w-3 h-3" />
+ </Button>
+ </Badge>
+ ))}
+ {selectedManagers.length > 1 && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={clearAll}
+ className="h-6 px-2 text-xs"
+ >
+ 전체 해제
+ </Button>
+ )}
+ </div>
+ )}
+
+ {/* 검색 입력 */}
+ <div className="relative">
+ <Input
+ ref={inputRef}
+ placeholder={selectedManagers.length === 0 ? placeholder : noValuePlaceHolder}
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ onFocus={() => setIsPopoverOpen(true)}
+ disabled={disabled}
+ className="w-full"
+ />
+
+ {/* 검색 결과 팝오버 */}
+ {isPopoverOpen && (
+ <div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-80 overflow-hidden">
+ {/* 검색 중 표시 */}
+ {isSearching && (
+ <div className="p-4 space-y-2">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <div key={i} className="flex items-center space-x-3">
+ <Skeleton className="h-4 w-4" />
+ <div className="space-y-2 flex-1">
+ <Skeleton className="h-4 w-3/4" />
+ <Skeleton className="h-3 w-1/2" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 검색 결과 */}
+ {!isSearching && searchResults.length > 0 && (
+ <div className="max-h-60 overflow-y-auto">
+ {searchResults.map((manager) => {
+ const isSelected = selectedManagers.some(m => m.id === manager.id)
+ return (
+ <div
+ key={manager.id}
+ className={cn(
+ "p-3 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0",
+ isSelected && "bg-blue-50"
+ )}
+ onClick={() => selectManager(manager)}
+ >
+ <div className="flex items-center justify-between">
+ <div className="flex-1">
+ <div className="font-medium text-sm">
+ {manager.departmentName}
+ </div>
+ <div className="text-xs text-gray-500">
+ {manager.managerName} ({manager.managerTitle})
+ </div>
+ <div className="text-xs text-gray-400">
+ {manager.companyName}
+ </div>
+ </div>
+ {isSelected && (
+ <Badge variant="secondary" className="text-xs">
+ 선택됨
+ </Badge>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ {/* 검색 결과 없음 */}
+ {!isSearching && searchQuery && searchResults.length === 0 && !searchError && (
+ <div className="p-4 text-center text-gray-500">
+ 검색 결과가 없습니다.
+ </div>
+ )}
+
+ {/* 오류 메시지 */}
+ {searchError && (
+ <div className="p-4 text-center text-red-500">
+ {searchError}
+ </div>
+ )}
+
+ {/* 페이지네이션 */}
+ {!isSearching && searchResults.length > 0 && (
+ <div className="flex items-center justify-between p-3 border-t border-gray-200">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage - 1)}
+ disabled={!pagination.hasPrevPage}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <span className="text-sm text-gray-500">
+ {currentPage} / {pagination.pageCount}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage + 1)}
+ disabled={!pagination.hasNextPage}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* 팝오버 외부 클릭 시 닫기 */}
+ {isPopoverOpen && (
+ <div
+ className="fixed inset-0 z-40"
+ onClick={() => setIsPopoverOpen(false)}
+ />
+ )}
+ </div>
+ )
+} \ No newline at end of file