diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-14 20:27:47 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-14 20:27:47 +0900 |
| commit | f63cf682d6846210a04ce4a3eb8ebe9afd6d6dba (patch) | |
| tree | 0a4717c11814c45e2b7d6531723700b1ae0a4974 /components/common/selectors/material/material-selector.tsx | |
| parent | d1cdcf9f35eca0552d1011e6d3c11a1d2d9abee4 (diff) | |
(김준회) 자재코드 선택기 구현 및 일반견적 생성시 반영
Diffstat (limited to 'components/common/selectors/material/material-selector.tsx')
| -rw-r--r-- | components/common/selectors/material/material-selector.tsx | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/components/common/selectors/material/material-selector.tsx b/components/common/selectors/material/material-selector.tsx new file mode 100644 index 00000000..0b37efec --- /dev/null +++ b/components/common/selectors/material/material-selector.tsx @@ -0,0 +1,395 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command"; +import { Check, ChevronsUpDown, X, Search, ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useDebounce } from "@/hooks/use-debounce"; +import { searchMaterialsForSelector, MaterialSearchItem } from "./material-service"; + +interface MaterialSelectorProps { + selectedMaterials?: MaterialSearchItem[]; + onMaterialsChange?: (materials: MaterialSearchItem[]) => void; + singleSelect?: boolean; + placeholder?: string; + noValuePlaceHolder?: string; + disabled?: boolean; + className?: string; + closeOnSelect?: boolean; + excludeMaterialCodes?: Set<string>; // 제외할 자재코드들 + showInitialData?: boolean; // 초기 클릭시 자재들을 로드할지 여부 + maxSelections?: number; // 최대 선택 가능한 자재 개수 (1이면 단일 선택, undefined면 제한 없음) +} + +export function MaterialSelector({ + selectedMaterials = [], + onMaterialsChange, + singleSelect = false, + placeholder = "자재코드를 검색하세요...", + noValuePlaceHolder = "자재를 검색해주세요", + disabled = false, + className, + closeOnSelect = true, + excludeMaterialCodes, + showInitialData = true, + maxSelections +}: MaterialSelectorProps) { + + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState<MaterialSearchItem[]>([]); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState<string | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const [initialDataLoaded, setInitialDataLoaded] = useState(false); + const [pagination, setPagination] = useState({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300); + + // 검색 실행 - useCallback으로 메모이제이션 + const performSearch = useCallback(async (query: string, page: number = 1) => { + setIsSearching(true); + setSearchError(null); + + try { + const result = await searchMaterialsForSelector(query, page, 10); + + 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); + } + }, []); + + // Popover 열림시 초기 데이터 로드 + React.useEffect(() => { + if (open && showInitialData && !initialDataLoaded && !searchQuery.trim()) { + setInitialDataLoaded(true); + performSearch("", 1); // 빈 쿼리로 초기 데이터 로드 + } + }, [open, showInitialData, initialDataLoaded, searchQuery, performSearch]); + + // Debounced 검색어 변경 시 검색 실행 (검색어가 있을 때만) + React.useEffect(() => { + if (debouncedSearchQuery.trim()) { + setCurrentPage(1); + performSearch(debouncedSearchQuery, 1); + } else if (showInitialData && initialDataLoaded) { + // 검색어가 없고 초기 데이터를 보여주는 경우 초기 데이터 유지 + // 아무것도 하지 않음 (기존 데이터 유지) + } else { + // 검색어가 없으면 결과 초기화 + setSearchResults([]); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } + }, [debouncedSearchQuery, performSearch, showInitialData, initialDataLoaded]); + + // 페이지 변경 처리 - useCallback으로 메모이제이션 + const handlePageChange = useCallback((newPage: number) => { + if (newPage >= 1 && newPage <= pagination.pageCount) { + const query = debouncedSearchQuery.trim() || (showInitialData && initialDataLoaded ? "" : debouncedSearchQuery); + performSearch(query, newPage); + } + }, [pagination.pageCount, performSearch, debouncedSearchQuery, showInitialData, initialDataLoaded]); + + // 자재 선택 처리 - useCallback으로 메모이제이션 + const handleMaterialSelect = useCallback((material: MaterialSearchItem) => { + if (disabled) return; + + let newSelectedMaterials: MaterialSearchItem[]; + + // maxSelections가 1이면 단일 선택 모드로 동작 + const isSingleSelectMode = singleSelect || maxSelections === 1; + + if (isSingleSelectMode) { + newSelectedMaterials = [material]; + } else { + const isAlreadySelected = selectedMaterials.some( + (selected) => selected.materialCode === material.materialCode && + selected.materialName === material.materialName + ); + + if (isAlreadySelected) { + newSelectedMaterials = selectedMaterials.filter( + (selected) => !(selected.materialCode === material.materialCode && + selected.materialName === material.materialName) + ); + } else { + // 최대 선택 개수 확인 + if (maxSelections && selectedMaterials.length >= maxSelections) { + // 최대 개수에 도달한 경우 선택하지 않음 + return; + } + newSelectedMaterials = [...selectedMaterials, material]; + } + } + + onMaterialsChange?.(newSelectedMaterials); + + if (closeOnSelect && isSingleSelectMode) { + setOpen(false); + } + }, [disabled, singleSelect, maxSelections, selectedMaterials, onMaterialsChange, closeOnSelect]); + + // 개별 자재 제거 + const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => { + if (disabled) return; + + const newSelectedMaterials = selectedMaterials.filter( + (material) => !(material.materialCode === materialToRemove.materialCode && + material.materialName === materialToRemove.materialName) + ); + onMaterialsChange?.(newSelectedMaterials); + }, [disabled, selectedMaterials, onMaterialsChange]); + + // 선택된 자재가 있는지 확인 + const isMaterialSelected = useCallback((material: MaterialSearchItem) => { + return selectedMaterials.some( + (selected) => selected.materialCode === material.materialCode && + selected.materialName === material.materialName + ); + }, [selectedMaterials]); + + return ( + <div className={cn("w-full", className)}> + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between min-h-[2.5rem] h-auto" + disabled={disabled} + > + <div className="flex flex-wrap gap-1 flex-1 text-left"> + {selectedMaterials.length === 0 ? ( + <span className="text-muted-foreground">{noValuePlaceHolder}</span> + ) : ( + selectedMaterials.map((material) => ( + <Badge + key={`${material.materialCode}-${material.materialName}`} + variant="secondary" + className="gap-1 pr-1" + > + <span className=""> + {material.displayText} + </span> + {!disabled && ( + <button + type="button" + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleRemoveMaterial(material); + }} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + )) + )} + </div> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0" align="start"> + <Command> + <div className="flex items-center border-b px-3"> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <Input + placeholder={placeholder} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none border-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50" + /> + </div> + + {/* 스크롤 컨테이너 + 고정 페이지네이션 */} + <div className="max-h-[50vh] flex flex-col"> + <CommandList + className="flex-1 overflow-y-auto overflow-x-hidden min-h-0" + // shadcn CommandList 버그 처리 - 스크롤 이벤트 전파 차단 + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + {!searchQuery.trim() && !showInitialData ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 자재코드를 검색하려면 검색어를 입력해주세요. + </div> + ) : !searchQuery.trim() && showInitialData && !initialDataLoaded ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 자재 목록을 로드하려면 클릭해주세요. + </div> + ) : isSearching ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 검색 중... + </div> + ) : searchError ? ( + <div className="p-4 text-center text-sm text-red-500"> + {searchError} + </div> + ) : searchResults.length === 0 ? ( + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + ) : ( + <CommandGroup className="overflow-visible"> + {searchResults.map((material) => { + const isExcluded = excludeMaterialCodes?.has(material.materialCode); + const isSelected = isMaterialSelected(material); + const isMaxReached = maxSelections && selectedMaterials.length >= maxSelections && !isSelected; + const isDisabled = isExcluded || isMaxReached; + + return ( + <CommandItem + key={`${material.materialCode}-${material.materialName}`} + onSelect={() => { + if (!isDisabled) { + handleMaterialSelect(material); + } + }} + className={cn( + "cursor-pointer", + isDisabled && "opacity-50 cursor-not-allowed bg-muted" + )} + > + <div className="mr-2 h-4 w-4 flex items-center justify-center"> + {isExcluded ? ( + <span className="text-xs text-muted-foreground">✓</span> + ) : isMaxReached ? ( + <span className="text-xs text-muted-foreground">-</span> + ) : ( + <Check + className={cn( + "h-4 w-4", + isSelected ? "opacity-100" : "opacity-0" + )} + /> + )} + </div> + <div className="flex-1"> + <div className={cn( + "font-medium", + isDisabled && "text-muted-foreground" + )}> + {material.materialName} + {isExcluded && ( + <span className="ml-2 text-xs bg-red-100 text-red-600 px-2 py-1 rounded"> + 이미 등록됨 + </span> + )} + {isMaxReached && ( + <span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"> + 선택 제한 ({maxSelections}개) + </span> + )} + </div> + <div className="text-xs text-muted-foreground"> + 자재코드: {material.materialCode} + </div> + </div> + </CommandItem> + ); + })} + </CommandGroup> + )} + </CommandList> + + {/* 고정 페이지네이션 - 항상 밑에 표시 */} + {searchResults.length > 0 && pagination.pageCount > 1 && ( + <div className="flex items-center justify-between border-t px-3 py-2 flex-shrink-0"> + <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-2"> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage} + className="h-6 w-6 p-0" + > + <ChevronLeft className="h-3 w-3" /> + </Button> + <span className="text-xs"> + {pagination.page} / {pagination.pageCount} + </span> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage} + className="h-6 w-6 p-0" + > + <ChevronRight className="h-3 w-3" /> + </Button> + </div> + </div> + )} + </div> + </Command> + </PopoverContent> + </Popover> + </div> + ); +} |
