diff options
| author | joonhoekim <26rote@gmail.com> | 2025-08-11 09:34:40 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-08-11 09:34:40 +0000 |
| commit | bcd462d6e60871b86008e072f4b914138fc5c328 (patch) | |
| tree | c22876fd6c6e7e48254587848b9dff50cdb8b032 /components/common/organization | |
| parent | cbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (diff) | |
(김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트), command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
Diffstat (limited to 'components/common/organization')
| -rw-r--r-- | components/common/organization/organization-manager-selector.tsx | 338 |
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 |
