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-dialog-multi.tsx | |
| parent | d1cdcf9f35eca0552d1011e6d3c11a1d2d9abee4 (diff) | |
(김준회) 자재코드 선택기 구현 및 일반견적 생성시 반영
Diffstat (limited to 'components/common/selectors/material/material-selector-dialog-multi.tsx')
| -rw-r--r-- | components/common/selectors/material/material-selector-dialog-multi.tsx | 538 |
1 files changed, 538 insertions, 0 deletions
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 + * <MaterialSelectorDialogMulti + * triggerLabel="자재 선택 (다중)" + * selectedMaterials={selectedMaterials} + * onMaterialsSelect={(materials) => { + * 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<string>; + /** 트리거 버튼에서 선택된 자재들을 표시할지 여부 */ + 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<MaterialSearchItem[]>([]); + + // 검색어 + const [searchQuery, setSearchQuery] = useState(""); + + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [searchResults, setSearchResults] = useState<MaterialSearchItem[]>([]); + 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<string | null>(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 ( + <div className="flex flex-wrap gap-1 items-center max-w-full"> + <span className="shrink-0">{triggerLabel}:</span> + {selectedMaterials.slice(0, 2).map((material) => ( + <Badge + key={`${material.materialCode}-${material.materialName}`} + variant="secondary" + className="gap-1 pr-1 max-w-[120px]" + > + <span className="truncate text-xs"> + {material.materialName} + </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) => handleRemoveMaterial(material, e)} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + ))} + {selectedMaterials.length > 2 && ( + <Badge variant="outline" className="text-xs"> + +{selectedMaterials.length - 2}개 + </Badge> + )} + </div> + ); + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button + variant={triggerVariant} + disabled={disabled} + className="min-h-[2.5rem] h-auto justify-start" + > + {renderTriggerContent()} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{dialogDescription}</DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 검색 입력창 */} + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> + <Input + placeholder={placeholder} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-10" + /> + </div> + + {/* 선택된 자재들 표시 */} + {tempSelectedMaterials.length > 0 && ( + <div className="p-3 bg-blue-50 rounded-lg border"> + <div className="flex items-center justify-between mb-2"> + <span className="text-sm font-medium text-blue-900"> + 선택됨: {tempSelectedMaterials.length} + {maxSelections && `/${maxSelections}`}개 + </span> + <Button variant="ghost" size="sm" onClick={handleClearAll}> + 전체 해제 + </Button> + </div> + <div className="flex flex-wrap gap-1"> + {tempSelectedMaterials.map((material) => ( + <Badge key={material.materialCode} variant="secondary" className="gap-1 pr-1"> + <span className="text-xs">{material.displayText}</span> + <button + type="button" + onClick={() => handleMaterialToggle(material)} + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + </Badge> + ))} + </div> + </div> + )} + + {/* 테이블 */} + <div className="border rounded-lg max-h-96 overflow-y-auto"> + {isSearching ? ( + <div className="p-8 text-center text-muted-foreground"> + 검색 중... + </div> + ) : error ? ( + <div className="p-8 text-center text-red-500"> + {error} + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-12">선택</TableHead> + <TableHead>자재코드</TableHead> + <TableHead>자재명</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {searchResults.length === 0 ? ( + <TableRow> + <TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> + {debouncedSearchQuery.trim() ? "검색 결과가 없습니다." : "자재가 없습니다."} + </TableCell> + </TableRow> + ) : ( + 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 ( + <TableRow + key={material.materialCode} + className={`cursor-pointer hover:bg-muted/50 ${ + isExcluded || isMaxReached ? 'opacity-50 cursor-not-allowed' : '' + } ${isSelected ? 'bg-blue-50' : ''}`} + onClick={() => { + if (!isExcluded && !isMaxReached) { + handleMaterialToggle(material); + } + }} + > + <TableCell> + <Checkbox + checked={isSelected} + disabled={isExcluded || isMaxReached} + onCheckedChange={() => { + if (!isExcluded && !isMaxReached) { + handleMaterialToggle(material); + } + }} + /> + </TableCell> + <TableCell className="font-mono text-sm"> + {material.materialCode} + {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"> + 최대 선택 + </span> + )} + </TableCell> + <TableCell>{material.materialName}</TableCell> + </TableRow> + ); + }) + )} + </TableBody> + </Table> + )} + </div> + + {/* 페이지네이션 */} + {searchResults.length > 0 && pagination.pageCount > 1 && ( + <div className="flex items-center justify-between"> + <div className="text-sm 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="outline" + size="sm" + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage || isSearching} + > + <ChevronLeft className="h-4 w-4" /> + 이전 + </Button> + <span className="text-sm"> + {pagination.page} / {pagination.pageCount} + </span> + <Button + variant="outline" + size="sm" + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage || isSearching} + > + 다음 + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + </div> + )} + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedMaterials.length > 0 && ( + <Button variant="ghost" onClick={handleClearAll}> + 전체 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 ({tempSelectedMaterials.length}개) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedMaterials, setSelectedMaterials] = useState<MaterialSearchItem[]>([]); + * + * return ( + * <MaterialSelectorDialogMulti + * triggerLabel="자재 선택" + * selectedMaterials={selectedMaterials} + * onMaterialsSelect={(materials) => { + * 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']); + * + * <MaterialSelectorDialogMulti + * selectedMaterials={selectedMaterials} + * onMaterialsSelect={setSelectedMaterials} + * excludeMaterialCodes={excludedCodes} + * maxSelections={3} + * triggerVariant="default" + * showSelectedInTrigger={false} + * /> + * ``` + */ |
