"use client"; import React, { useState, useRef, useEffect, useCallback } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Search, Link, Unlink } from "lucide-react"; import { toast } from "sonner"; // 타입 import import { ColumnInfo } from "@/lib/types/multiConnection"; import { FieldMapping, FieldMappingCanvasProps } from "../../types/redesigned"; // 컴포넌트 import import FieldColumn from "./FieldColumn"; import MappingControls from "./MappingControls"; /** * 🎨 시각적 필드 매핑 캔버스 * - SVG 기반 연결선 표시 * - 드래그 앤 드롭 지원 (향후) * - 실시간 연결선 업데이트 */ const FieldMappingCanvas: React.FC = ({ fromFields, toFields, mappings, onCreateMapping, onDeleteMapping, }) => { const [fromSearch, setFromSearch] = useState(""); const [toSearch, setToSearch] = useState(""); const [selectedFromField, setSelectedFromField] = useState(null); const [selectedToField, setSelectedToField] = useState(null); const [fieldPositions, setFieldPositions] = useState>({}); // 드래그 앤 드롭 상태 const [draggedField, setDraggedField] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const canvasRef = useRef(null); const fromColumnRef = useRef(null); const toColumnRef = useRef(null); const fieldRefs = useRef>({}); // 필드 필터링 - 안전한 배열 처리 const safeFromFields = Array.isArray(fromFields) ? fromFields : []; const safeToFields = Array.isArray(toFields) ? toFields : []; const filteredFromFields = safeFromFields.filter((field) => { const fieldName = field.displayName || field.columnName || ""; return fieldName.toLowerCase().includes(fromSearch.toLowerCase()); }); const filteredToFields = safeToFields.filter((field) => { const fieldName = field.displayName || field.columnName || ""; return fieldName.toLowerCase().includes(toSearch.toLowerCase()); }); // 매핑 생성 const handleCreateMapping = useCallback(() => { if (selectedFromField && selectedToField) { // 안전한 매핑 배열 처리 const safeMappings = Array.isArray(mappings) ? mappings : []; // N:1 매핑 방지 - TO 필드가 이미 매핑되어 있는지 확인 const existingToMapping = safeMappings.find((m) => m.toField.columnName === selectedToField.columnName); if (existingToMapping) { toast.error( `대상 필드 '${selectedToField.displayName || selectedToField.columnName}'는 이미 매핑되어 있습니다.\nN:1 매핑은 허용되지 않습니다.`, ); setSelectedFromField(null); setSelectedToField(null); return; } // 동일한 매핑 중복 체크 const existingMapping = safeMappings.find( (m) => m.fromField.columnName === selectedFromField.columnName && m.toField.columnName === selectedToField.columnName, ); if (existingMapping) { setSelectedFromField(null); setSelectedToField(null); return; } onCreateMapping(selectedFromField, selectedToField); setSelectedFromField(null); setSelectedToField(null); } }, [selectedFromField, selectedToField, mappings, onCreateMapping]); // 드래그 앤 드롭 핸들러들 const handleDragStart = useCallback((field: ColumnInfo) => { setDraggedField(field); setSelectedFromField(field); // 드래그 시작 시 선택 상태로 표시 }, []); const handleDragEnd = useCallback(() => { setDraggedField(null); setIsDragOver(false); }, []); // 드래그 오버 상태 관리 useEffect(() => { if (draggedField) { setIsDragOver(true); } else { setIsDragOver(false); } }, [draggedField]); const handleDrop = useCallback( (targetField: ColumnInfo, sourceField: ColumnInfo) => { // 안전한 매핑 배열 처리 const safeMappings = Array.isArray(mappings) ? mappings : []; // N:1 매핑 방지 - TO 필드가 이미 매핑되어 있는지 확인 const existingToMapping = safeMappings.find((m) => m.toField.columnName === targetField.columnName); if (existingToMapping) { toast.error( `대상 필드 '${targetField.displayName || targetField.columnName}'는 이미 매핑되어 있습니다.\nN:1 매핑은 허용되지 않습니다.`, ); setDraggedField(null); setIsDragOver(false); return; } // 동일한 매핑 중복 체크 const existingMapping = mappings.find( (m) => m.fromField.columnName === sourceField.columnName && m.toField.columnName === targetField.columnName, ); if (existingMapping) { setDraggedField(null); setIsDragOver(false); return; } // 매핑 생성 onCreateMapping(sourceField, targetField); // 상태 초기화 setDraggedField(null); setIsDragOver(false); setSelectedFromField(null); setSelectedToField(null); }, [mappings, onCreateMapping], ); // 필드 위치 업데이트 (메모이제이션) const updateFieldPosition = useCallback((fieldId: string, element: HTMLElement) => { if (!canvasRef.current) return; // fieldRefs에 저장 fieldRefs.current[fieldId] = element; const canvasRect = canvasRef.current.getBoundingClientRect(); const fieldRect = element.getBoundingClientRect(); const x = fieldRect.left - canvasRect.left + fieldRect.width / 2; const y = fieldRect.top - canvasRect.top + fieldRect.height / 2; setFieldPositions((prev) => { // 위치가 실제로 변경된 경우에만 업데이트 const currentPos = prev[fieldId]; if (currentPos && Math.abs(currentPos.x - x) < 1 && Math.abs(currentPos.y - y) < 1) { return prev; } return { ...prev, [fieldId]: { x, y }, }; }); }, []); // 스크롤 이벤트 리스너로 연결선 위치 업데이트 useEffect(() => { const updatePositionsOnScroll = () => { // 모든 필드의 위치를 다시 계산 Object.entries(fieldRefs.current || {}).forEach(([fieldId, element]) => { if (element) { updateFieldPosition(fieldId, element); } }); }; // 스크롤 가능한 영역들에 이벤트 리스너 추가 const scrollAreas = document.querySelectorAll("[data-radix-scroll-area-viewport]"); scrollAreas.forEach((area) => { area.addEventListener("scroll", updatePositionsOnScroll, { passive: true }); }); // 윈도우 리사이즈 시에도 위치 업데이트 window.addEventListener("resize", updatePositionsOnScroll, { passive: true }); return () => { scrollAreas.forEach((area) => { area.removeEventListener("scroll", updatePositionsOnScroll); }); window.removeEventListener("resize", updatePositionsOnScroll); }; }, [updateFieldPosition]); // 매핑 여부 확인 const isFieldMapped = useCallback( (field: ColumnInfo, type: "from" | "to") => { return mappings .filter((mapping) => mapping.fromField && mapping.toField) // 유효한 매핑만 확인 .some((mapping) => type === "from" ? mapping.fromField?.columnName === field.columnName : mapping.toField?.columnName === field.columnName, ); }, [mappings], ); // 연결선 데이터 생성 return (
{/* 매핑 생성 컨트롤 */}
{/* 필드 매핑 영역 */}
{/* FROM 필드 컬럼 */}

FROM 필드

{filteredFromFields.length}개
setFromSearch(e.target.value)} className="h-8 pl-9" />
{/* TO 필드 컬럼 */}

TO 필드

{filteredToFields.length}개
setToSearch(e.target.value)} className="h-8 pl-9" />
{/* 매핑 규칙 안내 */}

📋 매핑 규칙

✅ 1:N 매핑 허용 (하나의 소스 필드를 여러 대상 필드에 매핑)

❌ N:1 매핑 금지 (여러 소스 필드를 하나의 대상 필드에 매핑 불가)

🔒 이미 매핑된 대상 필드는 추가 매핑이 차단됩니다

🔗 {mappings.length}개 연결됨

); }; export default FieldMappingCanvas;