"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']); * * * ``` */