From 054cb69456825d964c8bd5a70a2b34c5e4b58262 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 4 Sep 2025 03:02:38 +0000 Subject: (김준회) 네이밍 변경: materialSelector --> materialGroupSelector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/common/material/README.md | 14 +- .../common/material/material-group-selector.tsx | 372 +++++++++++++++++++++ components/common/material/material-selector.tsx | 372 --------------------- 3 files changed, 379 insertions(+), 379 deletions(-) create mode 100644 components/common/material/material-group-selector.tsx delete mode 100644 components/common/material/material-selector.tsx (limited to 'components/common') diff --git a/components/common/material/README.md b/components/common/material/README.md index 30f16d38..341bbe80 100644 --- a/components/common/material/README.md +++ b/components/common/material/README.md @@ -1,4 +1,4 @@ -# MaterialSelector +# MaterialGroupSelector 자재그룹코드를 검색하고 선택할 수 있는 드롭다운 컴포넌트 @@ -15,13 +15,13 @@ ### 기본 사용법 ```tsx -import { MaterialSelector } from "@/components/common/material/material-selector"; +import { MaterialGroupSelector } from "@/components/common/material/material-group-selector"; function MyComponent() { const [selectedMaterials, setSelectedMaterials] = useState([]); return ( - @@ -31,7 +31,7 @@ function MyComponent() { ### 단일 선택 모드 ```tsx - void; + singleSelect?: boolean; + placeholder?: string; + noValuePlaceHolder?: string; + disabled?: boolean; + maxSelections?: number; + className?: string; + closeOnSelect?: boolean; + excludeMaterialCodes?: Set; // 제외할 자재그룹코드들 + showInitialData?: boolean; // 초기 클릭시 자재그룹들을 로드할지 여부 +} + +export function MaterialGroupSelector({ + selectedMaterials = [], + onMaterialsChange, + singleSelect = false, + placeholder = "자재를 검색하세요...", + noValuePlaceHolder = "자재를 검색해주세요", + disabled = false, + maxSelections, + className, + closeOnSelect = true, + excludeMaterialCodes, + showInitialData = true +}: MaterialGroupSelectorProps) { + + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(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[]; + + if (singleSelect) { + newSelectedMaterials = [material]; + } else { + const isAlreadySelected = selectedMaterials.some( + (selected) => selected.materialGroupCode === material.materialGroupCode && + selected.materialName === material.materialName + ); + + if (isAlreadySelected) { + newSelectedMaterials = selectedMaterials.filter( + (selected) => !(selected.materialGroupCode === material.materialGroupCode && + selected.materialName === material.materialName) + ); + } else { + if (maxSelections && selectedMaterials.length >= maxSelections) { + return; // 최대 선택 수 초과 시 추가하지 않음 + } + newSelectedMaterials = [...selectedMaterials, material]; + } + } + + onMaterialsChange?.(newSelectedMaterials); + + if (closeOnSelect && singleSelect) { + setOpen(false); + } + }, [disabled, singleSelect, selectedMaterials, maxSelections, onMaterialsChange, closeOnSelect]); + + // 개별 자재 제거 + const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => { + if (disabled) return; + + const newSelectedMaterials = selectedMaterials.filter( + (material) => !(material.materialGroupCode === materialToRemove.materialGroupCode && + material.materialName === materialToRemove.materialName) + ); + onMaterialsChange?.(newSelectedMaterials); + }, [disabled, selectedMaterials, onMaterialsChange]); + + // 선택된 자재가 있는지 확인 + const isMaterialSelected = useCallback((material: MaterialSearchItem) => { + return selectedMaterials.some( + (selected) => selected.materialGroupCode === material.materialGroupCode && + selected.materialName === material.materialName + ); + }, [selectedMaterials]); + + return ( +
+ + + + )} + + )) + )} +
+ + + + + + +
+ + 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" + /> +
+ + +
+ {!searchQuery.trim() && !showInitialData ? ( +
+ 자재를 검색하려면 검색어를 입력해주세요. +
+ ) : !searchQuery.trim() && showInitialData && !initialDataLoaded ? ( +
+ 자재 목록을 로드하려면 클릭해주세요. +
+ ) : isSearching ? ( +
+ 검색 중... +
+ ) : searchError ? ( +
+ {searchError} +
+ ) : searchResults.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + + {searchResults.map((material) => { + const isExcluded = excludeMaterialCodes?.has(material.materialGroupCode); + const isSelected = isMaterialSelected(material); + + return ( + { + if (!isExcluded) { + handleMaterialSelect(material); + } + }} + className={cn( + "cursor-pointer", + isExcluded && "opacity-50 cursor-not-allowed bg-muted" + )} + > +
+ {isExcluded ? ( + + ) : ( + + )} +
+
+
+ {material.materialName} + {isExcluded && ( + + 이미 등록됨 + + )} +
+
+ 자재그룹코드: {material.materialGroupCode} +
+
+
+ ); + })} +
+ )} +
+ + {/* 페이지네이션 */} + {searchResults.length > 0 && pagination.pageCount > 1 && ( +
+
+ 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}- + {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시 +
+
+ + + {pagination.page} / {pagination.pageCount} + + +
+
+ )} +
+
+
+ + + ); +} diff --git a/components/common/material/material-selector.tsx b/components/common/material/material-selector.tsx deleted file mode 100644 index 67b8c25c..00000000 --- a/components/common/material/material-selector.tsx +++ /dev/null @@ -1,372 +0,0 @@ -"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 "@/lib/material/material-group-service"; - -interface MaterialSelectorProps { - selectedMaterials?: MaterialSearchItem[]; - onMaterialsChange?: (materials: MaterialSearchItem[]) => void; - singleSelect?: boolean; - placeholder?: string; - noValuePlaceHolder?: string; - disabled?: boolean; - maxSelections?: number; - className?: string; - closeOnSelect?: boolean; - excludeMaterialCodes?: Set; // 제외할 자재그룹코드들 - showInitialData?: boolean; // 초기 클릭시 자재그룹들을 로드할지 여부 -} - -export function MaterialSelector({ - selectedMaterials = [], - onMaterialsChange, - singleSelect = false, - placeholder = "자재를 검색하세요...", - noValuePlaceHolder = "자재를 검색해주세요", - disabled = false, - maxSelections, - className, - closeOnSelect = true, - excludeMaterialCodes, - showInitialData = true -}: MaterialSelectorProps) { - - const [open, setOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [searchError, setSearchError] = useState(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[]; - - if (singleSelect) { - newSelectedMaterials = [material]; - } else { - const isAlreadySelected = selectedMaterials.some( - (selected) => selected.materialGroupCode === material.materialGroupCode && - selected.materialName === material.materialName - ); - - if (isAlreadySelected) { - newSelectedMaterials = selectedMaterials.filter( - (selected) => !(selected.materialGroupCode === material.materialGroupCode && - selected.materialName === material.materialName) - ); - } else { - if (maxSelections && selectedMaterials.length >= maxSelections) { - return; // 최대 선택 수 초과 시 추가하지 않음 - } - newSelectedMaterials = [...selectedMaterials, material]; - } - } - - onMaterialsChange?.(newSelectedMaterials); - - if (closeOnSelect && singleSelect) { - setOpen(false); - } - }, [disabled, singleSelect, selectedMaterials, maxSelections, onMaterialsChange, closeOnSelect]); - - // 개별 자재 제거 - const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => { - if (disabled) return; - - const newSelectedMaterials = selectedMaterials.filter( - (material) => !(material.materialGroupCode === materialToRemove.materialGroupCode && - material.materialName === materialToRemove.materialName) - ); - onMaterialsChange?.(newSelectedMaterials); - }, [disabled, selectedMaterials, onMaterialsChange]); - - // 선택된 자재가 있는지 확인 - const isMaterialSelected = useCallback((material: MaterialSearchItem) => { - return selectedMaterials.some( - (selected) => selected.materialGroupCode === material.materialGroupCode && - selected.materialName === material.materialName - ); - }, [selectedMaterials]); - - return ( -
- - - - )} - - )) - )} -
- - - - - - -
- - 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" - /> -
- - -
- {!searchQuery.trim() && !showInitialData ? ( -
- 자재를 검색하려면 검색어를 입력해주세요. -
- ) : !searchQuery.trim() && showInitialData && !initialDataLoaded ? ( -
- 자재 목록을 로드하려면 클릭해주세요. -
- ) : isSearching ? ( -
- 검색 중... -
- ) : searchError ? ( -
- {searchError} -
- ) : searchResults.length === 0 ? ( - 검색 결과가 없습니다. - ) : ( - - {searchResults.map((material) => { - const isExcluded = excludeMaterialCodes?.has(material.materialGroupCode); - const isSelected = isMaterialSelected(material); - - return ( - { - if (!isExcluded) { - handleMaterialSelect(material); - } - }} - className={cn( - "cursor-pointer", - isExcluded && "opacity-50 cursor-not-allowed bg-muted" - )} - > -
- {isExcluded ? ( - - ) : ( - - )} -
-
-
- {material.materialName} - {isExcluded && ( - - 이미 등록됨 - - )} -
-
- 자재그룹코드: {material.materialGroupCode} -
-
-
- ); - })} -
- )} -
- - {/* 페이지네이션 */} - {searchResults.length > 0 && pagination.pageCount > 1 && ( -
-
- 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}- - {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시 -
-
- - - {pagination.page} / {pagination.pageCount} - - -
-
- )} -
-
-
- - - ); -} -- cgit v1.2.3