From f63cf682d6846210a04ce4a3eb8ebe9afd6d6dba Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 14 Oct 2025 20:27:47 +0900 Subject: (김준회) 자재코드 선택기 구현 및 일반견적 생성시 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../material/material-selector-dialog-multi.tsx | 538 +++++++++++++++++++++ .../material/material-selector-dialog-single.tsx | 429 ++++++++++++++++ .../selectors/material/material-selector.tsx | 395 +++++++++++++++ .../common/selectors/material/material-service.ts | 155 ++++++ 4 files changed, 1517 insertions(+) create mode 100644 components/common/selectors/material/material-selector-dialog-multi.tsx create mode 100644 components/common/selectors/material/material-selector-dialog-single.tsx create mode 100644 components/common/selectors/material/material-selector.tsx create mode 100644 components/common/selectors/material/material-service.ts (limited to 'components') diff --git a/components/common/selectors/material/material-selector-dialog-multi.tsx b/components/common/selectors/material/material-selector-dialog-multi.tsx new file mode 100644 index 00000000..20d0a4f7 --- /dev/null +++ b/components/common/selectors/material/material-selector-dialog-multi.tsx @@ -0,0 +1,538 @@ +"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 { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Search, X, ChevronLeft, ChevronRight } from "lucide-react"; +import { useDebounce } from "@/hooks/use-debounce"; +import { searchMaterialsForDialog, MaterialSearchItem } from "./material-service"; + +/** + * 자재 다중 선택 Dialog 컴포넌트 + * + * @description + * - TanStack Table을 사용한 효율적인 자재 다중 선택 Dialog + * - 페이지네이션으로 데이터를 조회하고 클라이언트 사이드에서 검색 필터링 + * - 버튼 클릭 시 Dialog가 열리고, 여러 자재를 선택한 후 확인 버튼으로 완료 + * - 최대 선택 개수 제한 가능 + * + * @MaterialSearchItem_Structure + * 상태에서 관리되는 자재 객체의 형태: + * ```typescript + * interface MaterialSearchItem { + * materialCode: string; // 자재코드 (MATNR) + * materialName: string; // 자재명 (ZZNAME) + * displayText: string; // 표시용 텍스트 (code + " - " + name) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedMaterials: 현재 선택된 자재들 (배열) + * - tempSelectedMaterials: Dialog 내에서 임시로 선택된 자재들 (확인 버튼 클릭 전까지) + * - searchQuery: 검색어 + * - currentPage: 현재 페이지 + * - searchResults: 검색 결과 + * - isSearching: 검색 중 상태 + * - error: 에러 상태 + * + * @callback + * - onMaterialsSelect: 자재 선택 완료 시 호출되는 콜백 + * - 매개변수: MaterialSearchItem[] + * - 선택된 자재들의 배열 (빈 배열일 수도 있음) + * + * @usage + * ```tsx + * { + * setSelectedMaterials(materials); + * console.log('선택된 자재들:', materials); + * }} + * maxSelections={5} + * placeholder="자재코드를 검색하세요..." + * /> + * ``` + */ + +interface MaterialSelectorDialogMultiProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string; + /** 현재 선택된 자재들 */ + selectedMaterials?: MaterialSearchItem[]; + /** 자재 선택 완료 시 호출되는 콜백 */ + onMaterialsSelect?: (materials: MaterialSearchItem[]) => void; + /** 최대 선택 가능한 자재 개수 */ + maxSelections?: number; + /** 검색 입력창 placeholder */ + placeholder?: string; + /** Dialog 제목 */ + title?: string; + /** Dialog 설명 */ + description?: string; + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean; + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + /** 제외할 자재코드들 */ + excludeMaterialCodes?: Set; + /** 트리거 버튼에서 선택된 자재들을 표시할지 여부 */ + showSelectedInTrigger?: boolean; +} + +export function MaterialSelectorDialogMulti({ + triggerLabel = "자재 선택", + selectedMaterials = [], + onMaterialsSelect, + maxSelections, + placeholder = "자재코드를 검색하세요...", + title = "자재 선택 (다중)", + description, + disabled = false, + triggerVariant = "outline", + excludeMaterialCodes, + showSelectedInTrigger = true, +}: MaterialSelectorDialogMultiProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false); + + // Dialog 내에서 임시로 선택된 자재들 (확인 버튼 클릭 전까지) + const [tempSelectedMaterials, setTempSelectedMaterials] = useState([]); + + // 검색어 + const [searchQuery, setSearchQuery] = useState(""); + + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [searchResults, setSearchResults] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + + // 로딩 상태 + const [isSearching, setIsSearching] = useState(false); + + // 에러 상태 + const [error, setError] = useState(null); + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300); + + // Dialog 설명 동적 생성 + const dialogDescription = description || + (maxSelections + ? `원하는 자재를 검색하고 선택해주세요. (최대 ${maxSelections}개)` + : "원하는 자재를 검색하고 선택해주세요." + ); + + // 검색 실행 함수 + const performSearch = useCallback(async (query: string, page: number = 1) => { + setIsSearching(true); + setError(null); + + try { + const result = await searchMaterialsForDialog(query, page, 10); + + if (result.success) { + setSearchResults(result.data); + setPagination(result.pagination); + setCurrentPage(page); + } else { + setSearchResults([]); + setError(result.error || "검색 중 오류가 발생했습니다."); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } + } catch (err) { + console.error("자재 검색 실패:", err); + setSearchResults([]); + setError("검색 중 오류가 발생했습니다."); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } finally { + setIsSearching(false); + } + }, []); + + // Dialog 열림 시 초기화 및 첫 검색 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + setTempSelectedMaterials([...selectedMaterials]); + setSearchQuery(""); + setCurrentPage(1); + // 초기 데이터 로드 + performSearch("", 1); + } + }, [selectedMaterials, performSearch]); + + // 검색어 변경 시 검색 실행 + React.useEffect(() => { + if (open) { + setCurrentPage(1); + performSearch(debouncedSearchQuery, 1); + } + }, [debouncedSearchQuery, performSearch, open]); + + // 페이지 변경 처리 + const handlePageChange = useCallback((newPage: number) => { + if (newPage >= 1 && newPage <= pagination.pageCount) { + setCurrentPage(newPage); + performSearch(debouncedSearchQuery, newPage); + } + }, [pagination.pageCount, performSearch, debouncedSearchQuery]); + + // 자재 선택/해제 처리 + const handleMaterialToggle = useCallback((material: MaterialSearchItem) => { + setTempSelectedMaterials(prev => { + const isSelected = prev.some(m => m.materialCode === material.materialCode); + if (isSelected) { + // 선택 해제 + return prev.filter(m => m.materialCode !== material.materialCode); + } else { + // 선택 추가 (최대 개수 확인) + if (maxSelections && prev.length >= maxSelections) { + return prev; // 최대 개수에 도달한 경우 추가하지 않음 + } + return [...prev, material]; + } + }); + }, [maxSelections]); + + // 전체 선택 해제 + const handleClearAll = useCallback(() => { + setTempSelectedMaterials([]); + }, []); + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onMaterialsSelect?.(tempSelectedMaterials); + setOpen(false); + }, [tempSelectedMaterials, onMaterialsSelect]); + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedMaterials([...selectedMaterials]); + setOpen(false); + }, [selectedMaterials]); + + // 개별 자재 제거 (트리거 버튼에서) + const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const newMaterials = selectedMaterials.filter( + (material) => !(material.materialCode === materialToRemove.materialCode && + material.materialName === materialToRemove.materialName) + ); + onMaterialsSelect?.(newMaterials); + }, [selectedMaterials, onMaterialsSelect]); + + // 트리거 버튼 렌더링 + const renderTriggerContent = () => { + if (selectedMaterials.length === 0) { + return triggerLabel; + } + + if (!showSelectedInTrigger) { + return `${triggerLabel} (${selectedMaterials.length}개)`; + } + + return ( +
+ {triggerLabel}: + {selectedMaterials.slice(0, 2).map((material) => ( + + + {material.materialName} + + {!disabled && ( + + )} + + ))} + {selectedMaterials.length > 2 && ( + + +{selectedMaterials.length - 2}개 + + )} +
+ ); + }; + + return ( + + + + + + + + {title} + {dialogDescription} + + +
+ {/* 검색 입력창 */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* 선택된 자재들 표시 */} + {tempSelectedMaterials.length > 0 && ( +
+
+ + 선택됨: {tempSelectedMaterials.length} + {maxSelections && `/${maxSelections}`}개 + + +
+
+ {tempSelectedMaterials.map((material) => ( + + {material.displayText} + + + ))} +
+
+ )} + + {/* 테이블 */} +
+ {isSearching ? ( +
+ 검색 중... +
+ ) : error ? ( +
+ {error} +
+ ) : ( + + + + 선택 + 자재코드 + 자재명 + + + + {searchResults.length === 0 ? ( + + + {debouncedSearchQuery.trim() ? "검색 결과가 없습니다." : "자재가 없습니다."} + + + ) : ( + searchResults.map((material) => { + const isExcluded = excludeMaterialCodes?.has(material.materialCode); + const isSelected = tempSelectedMaterials.some(m => m.materialCode === material.materialCode); + const isMaxReached = Boolean(maxSelections && tempSelectedMaterials.length >= maxSelections && !isSelected); + + return ( + { + if (!isExcluded && !isMaxReached) { + handleMaterialToggle(material); + } + }} + > + + { + if (!isExcluded && !isMaxReached) { + handleMaterialToggle(material); + } + }} + /> + + + {material.materialCode} + {isExcluded && ( + + 제외됨 + + )} + {isMaxReached && ( + + 최대 선택 + + )} + + {material.materialName} + + ); + }) + )} + +
+ )} +
+ + {/* 페이지네이션 */} + {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} + + +
+
+ )} +
+ + + + {tempSelectedMaterials.length > 0 && ( + + )} + + +
+
+ ); +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedMaterials, setSelectedMaterials] = useState([]); + * + * return ( + * { + * setSelectedMaterials(materials); + * console.log('선택된 자재들:', materials.map(m => ({ + * code: m.materialCode, + * name: m.materialName, + * displayText: m.displayText + * }))); + * }} + * maxSelections={5} + * title="필수 자재 선택" + * description="프로젝트에 필요한 자재들을 선택해주세요." + * showSelectedInTrigger={true} + * /> + * ); + * ``` + * + * @advanced_usage + * ```tsx + * // 제외할 자재가 있는 경우 + * const excludedCodes = new Set(['MAT001', 'MAT002']); + * + * + * ``` + */ diff --git a/components/common/selectors/material/material-selector-dialog-single.tsx b/components/common/selectors/material/material-selector-dialog-single.tsx new file mode 100644 index 00000000..0a3b0920 --- /dev/null +++ b/components/common/selectors/material/material-selector-dialog-single.tsx @@ -0,0 +1,429 @@ +"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 { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Search, X, ChevronLeft, ChevronRight } from "lucide-react"; +import { useDebounce } from "@/hooks/use-debounce"; +import { searchMaterialsForDialog, MaterialSearchItem } from "./material-service"; + +/** + * 자재 단일 선택 Dialog 컴포넌트 + * + * @description + * - TanStack Table을 사용한 효율적인 자재 선택 Dialog + * - 페이지네이션으로 데이터를 조회하고 클라이언트 사이드에서 검색 필터링 + * - 버튼 클릭 시 Dialog가 열리고, 자재를 선택하면 Dialog가 닫히며 결과를 반환 + * + * @MaterialSearchItem_Structure + * 상태에서 관리되는 자재 객체의 형태: + * ```typescript + * interface MaterialSearchItem { + * materialCode: string; // 자재코드 (MATNR) + * materialName: string; // 자재명 (ZZNAME) + * displayText: string; // 표시용 텍스트 (code + " - " + name) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedMaterial: 현재 선택된 자재 (단일) + * - tempSelectedMaterial: Dialog 내에서 임시로 선택된 자재 (확인 버튼 클릭 전까지) + * - searchQuery: 검색어 + * - currentPage: 현재 페이지 + * - searchResults: 검색 결과 + * - isSearching: 검색 중 상태 + * - error: 에러 상태 + * + * @callback + * - onMaterialSelect: 자재 선택 완료 시 호출되는 콜백 + * - 매개변수: MaterialSearchItem | null + * - 선택된 자재 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * { + * setSelectedMaterial(material); + * console.log('선택된 자재:', material); + * }} + * placeholder="자재코드를 검색하세요..." + * /> + * ``` + */ + +interface MaterialSelectorDialogSingleProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string; + /** 현재 선택된 자재 */ + selectedMaterial?: MaterialSearchItem | null; + /** 자재 선택 완료 시 호출되는 콜백 */ + onMaterialSelect?: (material: MaterialSearchItem | null) => void; + /** 검색 입력창 placeholder */ + placeholder?: string; + /** Dialog 제목 */ + title?: string; + /** Dialog 설명 */ + description?: string; + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean; + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + /** 제외할 자재코드들 */ + excludeMaterialCodes?: Set; +} + +export function MaterialSelectorDialogSingle({ + triggerLabel = "자재 선택", + selectedMaterial = null, + onMaterialSelect, + placeholder = "자재코드를 검색하세요...", + title = "자재 선택", + description = "원하는 자재를 검색하고 선택해주세요.", + disabled = false, + triggerVariant = "outline", + excludeMaterialCodes, +}: MaterialSelectorDialogSingleProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false); + + // Dialog 내에서 임시로 선택된 자재 (확인 버튼 클릭 전까지) + const [tempSelectedMaterial, setTempSelectedMaterial] = useState(null); + + // 검색어 + const [searchQuery, setSearchQuery] = useState(""); + + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [searchResults, setSearchResults] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + + // 로딩 상태 + const [isSearching, setIsSearching] = useState(false); + + // 에러 상태 + const [error, setError] = useState(null); + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300); + + // 검색 실행 함수 + const performSearch = useCallback(async (query: string, page: number = 1) => { + setIsSearching(true); + setError(null); + + try { + const result = await searchMaterialsForDialog(query, page, 10); + + if (result.success) { + setSearchResults(result.data); + setPagination(result.pagination); + setCurrentPage(page); + } else { + setSearchResults([]); + setError(result.error || "검색 중 오류가 발생했습니다."); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } + } catch (err) { + console.error("자재 검색 실패:", err); + setSearchResults([]); + setError("검색 중 오류가 발생했습니다."); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } finally { + setIsSearching(false); + } + }, []); + + // Dialog 열림 시 초기화 및 첫 검색 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + setTempSelectedMaterial(selectedMaterial || null); + setSearchQuery(""); + setCurrentPage(1); + // 초기 데이터 로드 + performSearch("", 1); + } + }, [selectedMaterial, performSearch]); + + // 검색어 변경 시 검색 실행 + React.useEffect(() => { + if (open) { + setCurrentPage(1); + performSearch(debouncedSearchQuery, 1); + } + }, [debouncedSearchQuery, performSearch, open]); + + // 페이지 변경 처리 + const handlePageChange = useCallback((newPage: number) => { + if (newPage >= 1 && newPage <= pagination.pageCount) { + setCurrentPage(newPage); + performSearch(debouncedSearchQuery, newPage); + } + }, [pagination.pageCount, performSearch, debouncedSearchQuery]); + + // 자재 선택 처리 + const handleMaterialSelect = useCallback((material: MaterialSearchItem) => { + setTempSelectedMaterial(material); + }, []); + + // 선택 해제 + const handleClear = useCallback(() => { + setTempSelectedMaterial(null); + }, []); + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onMaterialSelect?.(tempSelectedMaterial); + setOpen(false); + }, [tempSelectedMaterial, onMaterialSelect]); + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedMaterial(selectedMaterial || null); + setOpen(false); + }, [selectedMaterial]); + + return ( + + + + + + + + {title} + {description} + + +
+ {/* 검색 입력창 */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* 선택된 자재 표시 */} + {tempSelectedMaterial && ( +
+ 선택됨: + + {tempSelectedMaterial.displayText} + + +
+ )} + + {/* 테이블 */} +
+ {isSearching ? ( +
+ 검색 중... +
+ ) : error ? ( +
+ {error} +
+ ) : ( + + + + 선택 + 자재코드 + 자재명 + + + + {searchResults.length === 0 ? ( + + + {debouncedSearchQuery.trim() ? "검색 결과가 없습니다." : "자재가 없습니다."} + + + ) : ( + searchResults.map((material) => { + const isExcluded = excludeMaterialCodes?.has(material.materialCode); + const isSelected = tempSelectedMaterial?.materialCode === material.materialCode; + + return ( + { + if (!isExcluded) { + handleMaterialSelect(material); + } + }} + > + + { + if (!isExcluded) { + handleMaterialSelect(material); + } + }} + /> + + + {material.materialCode} + {isExcluded && ( + + 제외됨 + + )} + + {material.materialName} + + ); + }) + )} + +
+ )} +
+ + {/* 페이지네이션 */} + {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} + + +
+
+ )} +
+ + + + {tempSelectedMaterial && ( + + )} + + +
+
+ ); +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedMaterial, setSelectedMaterial] = useState(null); + * + * return ( + * { + * setSelectedMaterial(material); + * if (material) { + * console.log('선택된 자재:', { + * code: material.materialCode, + * name: material.materialName, + * displayText: material.displayText + * }); + * } else { + * console.log('자재 선택이 해제되었습니다.'); + * } + * }} + * title="자재 선택" + * description="필요한 자재를 검색하고 선택해주세요." + * /> + * ); + * ``` + */ 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; // 제외할 자재코드들 + 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([]); + 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[]; + + // 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 ( +
+ + + + )} + + )) + )} +
+ + + + + + +
+ + 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" + /> +
+ + {/* 스크롤 컨테이너 + 고정 페이지네이션 */} +
+ { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + {!searchQuery.trim() && !showInitialData ? ( +
+ 자재코드를 검색하려면 검색어를 입력해주세요. +
+ ) : !searchQuery.trim() && showInitialData && !initialDataLoaded ? ( +
+ 자재 목록을 로드하려면 클릭해주세요. +
+ ) : isSearching ? ( +
+ 검색 중... +
+ ) : searchError ? ( +
+ {searchError} +
+ ) : searchResults.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + + {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 ( + { + if (!isDisabled) { + handleMaterialSelect(material); + } + }} + className={cn( + "cursor-pointer", + isDisabled && "opacity-50 cursor-not-allowed bg-muted" + )} + > +
+ {isExcluded ? ( + + ) : isMaxReached ? ( + - + ) : ( + + )} +
+
+
+ {material.materialName} + {isExcluded && ( + + 이미 등록됨 + + )} + {isMaxReached && ( + + 선택 제한 ({maxSelections}개) + + )} +
+
+ 자재코드: {material.materialCode} +
+
+
+ ); + })} +
+ )} +
+ + {/* 고정 페이지네이션 - 항상 밑에 표시 */} + {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/selectors/material/material-service.ts b/components/common/selectors/material/material-service.ts new file mode 100644 index 00000000..1f238ed7 --- /dev/null +++ b/components/common/selectors/material/material-service.ts @@ -0,0 +1,155 @@ +"use server"; + +import { sql, SQL } from "drizzle-orm"; +import db from "@/db/db"; +import { MATERIAL_MASTER_PART_MATL } from "@/db/schema/MDG/mdg"; + +export interface MaterialSearchItem { + materialCode: string; // 자재코드 (MATNR) + materialName: string; // 자재명 (ZZNAME) + displayText: string; // 애플리케이션 레벨에서 계산된 필드 +} + +export interface MaterialSearchResult { + success: boolean; + data: MaterialSearchItem[]; + pagination: { + page: number; + perPage: number; + total: number; + pageCount: number; + hasNextPage: boolean; + hasPrevPage: boolean; + }; +} + +/** + * 자재 검색 함수 - MATERIAL_MASTER_PART_MATL 테이블에서 검색 (페이지네이션) + * 사용자는 자재코드를 직접 입력하여 검색함 (자재명이 아닌 자재코드만 검색 대상) + * 자재 레코드가 수억개 수준이므로 항상 페이지네이션 사용 + */ +export async function searchMaterialsForSelector( + query: string, + page: number = 1, + perPage: number = 10 +): Promise { + try { + const offset = (page - 1) * perPage; + + // 검색 조건 - 자재코드(MATNR)로만 검색 + let searchWhere: SQL | undefined; + if (query.trim()) { + const searchPattern = `%${query.trim()}%`; + searchWhere = sql`${MATERIAL_MASTER_PART_MATL.MATNR} ILIKE ${searchPattern}`; + } + + const { data, total } = await db.transaction(async (tx) => { + // 데이터 조회 - MATNR과 ZZNAME만 선택 + const data = await tx + .select({ + materialCode: MATERIAL_MASTER_PART_MATL.MATNR, + materialName: MATERIAL_MASTER_PART_MATL.ZZNAME, + }) + .from(MATERIAL_MASTER_PART_MATL) + .where(searchWhere) + .orderBy(MATERIAL_MASTER_PART_MATL.MATNR) + .limit(perPage) + .offset(offset); + + // 총 개수 조회 + const countResult = await tx + .select({ count: sql`count(*)` }) + .from(MATERIAL_MASTER_PART_MATL) + .where(searchWhere); + + const total = countResult[0]?.count || 0; + + return { + data: data.map((row) => ({ + materialCode: row.materialCode || '', + materialName: row.materialName || '', + displayText: `${row.materialCode || ''} - ${row.materialName || ''}`, // 애플리케이션 레벨에서 생성 + })), + total, + }; + }); + + const pageCount = Math.ceil(total / perPage); + + return { + success: true, + data, + pagination: { + page, + perPage, + total, + pageCount, + hasNextPage: page < pageCount, + hasPrevPage: page > 1, + }, + }; + } catch (error) { + console.error("자재 검색 오류:", error); + return { + success: false, + data: [], + pagination: { + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }, + }; + } +} + +/** + * Dialog용 자재 목록 조회 함수 - 페이지네이션 + * Dialog에서는 효율적인 사용자 경험을 위해 10개씩 페이지네이션하여 조회 + * 자재 레코드가 수억개 수준이므로 무한 스크롤이나 전체 로드가 아닌 페이지네이션 사용 + */ +export async function searchMaterialsForDialog( + query: string, + page: number = 1, + perPage: number = 10 +): Promise<{ + success: boolean; + data: MaterialSearchItem[]; + pagination: { + page: number; + perPage: number; + total: number; + pageCount: number; + hasNextPage: boolean; + hasPrevPage: boolean; + }; + error?: string; +}> { + try { + const result = await searchMaterialsForSelector(query, page, perPage); + + return { + success: result.success, + data: result.data, + pagination: result.pagination, + error: result.success ? undefined : "검색 중 오류가 발생했습니다.", + }; + } catch (error) { + console.error("Dialog용 자재 검색 오류:", error); + return { + success: false, + data: [], + pagination: { + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }, + error: "데이터를 불러오는데 실패했습니다.", + }; + } +} \ No newline at end of file -- cgit v1.2.3