제어관리 외부커넥션 설정기능
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ConnectionLineProps {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
mapping: any;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔗 SVG 연결선 컴포넌트
|
||||
* - 베지어 곡선으로 부드러운 연결선 표시
|
||||
* - 유효성에 따른 색상 변경
|
||||
* - 호버 시 삭제 버튼 표시
|
||||
*/
|
||||
const ConnectionLine: React.FC<ConnectionLineProps> = ({ id, fromX, fromY, toX, toY, isValid, mapping, onDelete }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// 베지어 곡선 제어점 계산
|
||||
const controlPointOffset = Math.abs(toX - fromX) * 0.5;
|
||||
const controlPoint1X = fromX + controlPointOffset;
|
||||
const controlPoint1Y = fromY;
|
||||
const controlPoint2X = toX - controlPointOffset;
|
||||
const controlPoint2Y = toY;
|
||||
|
||||
// 패스 생성
|
||||
const pathData = `M ${fromX} ${fromY} C ${controlPoint1X} ${controlPoint1Y}, ${controlPoint2X} ${controlPoint2Y}, ${toX} ${toY}`;
|
||||
|
||||
// 색상 결정
|
||||
const strokeColor = isValid
|
||||
? isHovered
|
||||
? "#10b981" // green-500 hover
|
||||
: "#22c55e" // green-500
|
||||
: isHovered
|
||||
? "#f97316" // orange-500 hover
|
||||
: "#fb923c"; // orange-400
|
||||
|
||||
// 중간점 계산 (삭제 버튼 위치)
|
||||
const midX = (fromX + toX) / 2;
|
||||
const midY = (fromY + toY) / 2;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 연결선 - 더 부드럽고 덜 방해되는 스타일 */}
|
||||
<path
|
||||
d={pathData}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isHovered ? "2.5" : "1.5"}
|
||||
fill="none"
|
||||
opacity={isHovered ? "0.9" : "0.6"}
|
||||
strokeDasharray="0"
|
||||
className="cursor-pointer transition-all duration-300"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{ pointerEvents: "stroke" }}
|
||||
/>
|
||||
|
||||
{/* 연결선 위의 투명한 넓은 영역 (호버 감지용) */}
|
||||
<path
|
||||
d={pathData}
|
||||
stroke="transparent"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{ pointerEvents: "stroke" }}
|
||||
/>
|
||||
|
||||
{/* 시작점 원 */}
|
||||
<circle
|
||||
cx={fromX}
|
||||
cy={fromY}
|
||||
r={isHovered ? "3.5" : "2.5"}
|
||||
fill={strokeColor}
|
||||
opacity={isHovered ? "0.9" : "0.7"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* 끝점 원 */}
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
r={isHovered ? "3.5" : "2.5"}
|
||||
fill={strokeColor}
|
||||
opacity={isHovered ? "0.9" : "0.7"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* 호버 시 삭제 버튼 */}
|
||||
{isHovered && (
|
||||
<g>
|
||||
{/* 삭제 버튼 배경 */}
|
||||
<circle
|
||||
cx={midX}
|
||||
cy={midY}
|
||||
r="12"
|
||||
fill="white"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="2"
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{ pointerEvents: "all" }}
|
||||
/>
|
||||
|
||||
{/* X 아이콘 */}
|
||||
<g
|
||||
transform={`translate(${midX - 4}, ${midY - 4})`}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{ pointerEvents: "all" }}
|
||||
>
|
||||
<path d="M1 1L7 7M7 1L1 7" stroke={strokeColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* 매핑 정보 툴팁 (호버 시) */}
|
||||
{isHovered && (
|
||||
<g>
|
||||
<rect
|
||||
x={midX - 60}
|
||||
y={midY - 35}
|
||||
width="120"
|
||||
height="20"
|
||||
rx="4"
|
||||
fill="rgba(0, 0, 0, 0.8)"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
<text x={midX} y={midY - 22} textAnchor="middle" fill="white" fontSize="10" style={{ pointerEvents: "none" }}>
|
||||
{mapping.fromField.webType} → {mapping.toField.webType}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionLine;
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link, GripVertical } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface FieldColumnProps {
|
||||
fields: ColumnInfo[];
|
||||
type: "from" | "to";
|
||||
selectedField: ColumnInfo | null;
|
||||
onFieldSelect: (field: ColumnInfo | null) => void;
|
||||
onFieldPositionUpdate: (fieldId: string, element: HTMLElement) => void;
|
||||
isFieldMapped: (field: ColumnInfo, type: "from" | "to") => boolean;
|
||||
onDragStart?: (field: ColumnInfo) => void;
|
||||
onDragEnd?: () => void;
|
||||
onDrop?: (targetField: ColumnInfo, sourceField: ColumnInfo) => void;
|
||||
isDragOver?: boolean;
|
||||
draggedField?: ColumnInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 필드 컬럼 컴포넌트
|
||||
* - 필드 목록 표시
|
||||
* - 선택 상태 관리
|
||||
* - 위치 정보 업데이트
|
||||
*/
|
||||
const FieldColumn: React.FC<FieldColumnProps> = ({
|
||||
fields,
|
||||
type,
|
||||
selectedField,
|
||||
onFieldSelect,
|
||||
onFieldPositionUpdate,
|
||||
isFieldMapped,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
isDragOver,
|
||||
draggedField,
|
||||
}) => {
|
||||
const fieldRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
|
||||
// 필드 위치 업데이트
|
||||
useEffect(() => {
|
||||
const updatePositions = () => {
|
||||
Object.entries(fieldRefs.current).forEach(([fieldId, element]) => {
|
||||
if (element) {
|
||||
onFieldPositionUpdate(fieldId, element);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 약간의 지연을 두어 DOM이 완전히 렌더링된 후 위치 업데이트
|
||||
const timeoutId = setTimeout(updatePositions, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [fields.length]); // fields 배열 대신 length만 의존성으로 사용
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragStart = (e: React.DragEvent, field: ColumnInfo) => {
|
||||
if (type === "from" && onDragStart) {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify(field));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
onDragStart(field);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
if (onDragEnd) {
|
||||
onDragEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (type === "to") {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetField: ColumnInfo) => {
|
||||
if (type === "to" && onDrop) {
|
||||
e.preventDefault();
|
||||
|
||||
// 이미 매핑된 TO 필드인지 확인
|
||||
const isMapped = isFieldMapped(targetField, "to");
|
||||
if (isMapped) {
|
||||
// 이미 매핑된 필드에는 드롭할 수 없음을 시각적으로 표시
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceFieldData = e.dataTransfer.getData("text/plain");
|
||||
const sourceField = JSON.parse(sourceFieldData) as ColumnInfo;
|
||||
onDrop(targetField, sourceField);
|
||||
} catch (error) {
|
||||
console.error("드롭 처리 중 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 렌더링
|
||||
const renderField = (field: ColumnInfo, index: number) => {
|
||||
const fieldId = `${type}_${field.columnName}`;
|
||||
const isSelected = selectedField?.columnName === field.columnName;
|
||||
const isMapped = isFieldMapped(field, type);
|
||||
const displayName = field.displayName || field.columnName;
|
||||
const isDragging = draggedField?.columnName === field.columnName;
|
||||
const isDropTarget = type === "to" && isDragOver && draggedField && !isMapped;
|
||||
const isBlockedDropTarget = type === "to" && isDragOver && draggedField && isMapped;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${type}_${field.columnName}_${index}`}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
fieldRefs.current[fieldId] = el;
|
||||
}
|
||||
}}
|
||||
className={`relative cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/20 scale-105 transform opacity-50 shadow-lg"
|
||||
: isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: isMapped
|
||||
? "border-green-500 bg-green-50 shadow-sm"
|
||||
: isBlockedDropTarget
|
||||
? "border-red-400 bg-red-50 shadow-md"
|
||||
: isDropTarget
|
||||
? "border-blue-400 bg-blue-50 shadow-md"
|
||||
: "border-border hover:bg-muted/50 hover:shadow-sm"
|
||||
} `}
|
||||
draggable={type === "from" && !isMapped}
|
||||
onDragStart={(e) => handleDragStart(e, field)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
onClick={() => onFieldSelect(isSelected ? null : field)}
|
||||
>
|
||||
{/* 연결점 표시 */}
|
||||
<div
|
||||
className={`absolute ${type === "from" ? "right-0" : "left-0"} top-1/2 h-3 w-3 -translate-y-1/2 transform rounded-full border-2 transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary border-primary"
|
||||
: isMapped
|
||||
? "border-green-500 bg-green-500"
|
||||
: "border-gray-300 bg-white"
|
||||
} `}
|
||||
style={{
|
||||
[type === "from" ? "right" : "left"]: "-6px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{type === "from" && !isMapped && <GripVertical className="h-3 w-3 flex-shrink-0 text-gray-400" />}
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
{isMapped && <Link className="h-3 w-3 flex-shrink-0 text-green-600" />}
|
||||
</div>
|
||||
<Badge variant="outline" className="flex-shrink-0 text-xs">
|
||||
{field.webType || field.dataType || "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{field.description && <p className="text-muted-foreground mt-1 truncate text-xs">{field.description}</p>}
|
||||
|
||||
{/* 선택 상태 표시 */}
|
||||
{isSelected && <div className="border-primary pointer-events-none absolute inset-0 rounded-lg border-2" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ScrollArea className="h-full rounded-lg border">
|
||||
<div className="space-y-2 p-2">
|
||||
{fields.map((field, index) => renderField(field, index))}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
<p>필드가 없습니다.</p>
|
||||
<p className="mt-1 text-xs">테이블을 선택해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldColumn;
|
||||
@@ -0,0 +1,325 @@
|
||||
"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<FieldMappingCanvasProps> = ({
|
||||
fromFields,
|
||||
toFields,
|
||||
mappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
}) => {
|
||||
const [fromSearch, setFromSearch] = useState("");
|
||||
const [toSearch, setToSearch] = useState("");
|
||||
const [selectedFromField, setSelectedFromField] = useState<ColumnInfo | null>(null);
|
||||
const [selectedToField, setSelectedToField] = useState<ColumnInfo | null>(null);
|
||||
const [fieldPositions, setFieldPositions] = useState<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// 드래그 앤 드롭 상태
|
||||
const [draggedField, setDraggedField] = useState<ColumnInfo | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const fromColumnRef = useRef<HTMLDivElement>(null);
|
||||
const toColumnRef = useRef<HTMLDivElement>(null);
|
||||
const fieldRefs = useRef<Record<string, HTMLElement>>({});
|
||||
|
||||
// 필드 필터링 - 안전한 배열 처리
|
||||
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.some((mapping) =>
|
||||
type === "from"
|
||||
? mapping.fromField.columnName === field.columnName
|
||||
: mapping.toField.columnName === field.columnName,
|
||||
);
|
||||
},
|
||||
[mappings],
|
||||
);
|
||||
|
||||
// 연결선 데이터 생성
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} className="relative flex h-full flex-col">
|
||||
{/* 매핑 생성 컨트롤 */}
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<MappingControls
|
||||
selectedFromField={selectedFromField}
|
||||
selectedToField={selectedToField}
|
||||
onCreateMapping={handleCreateMapping}
|
||||
canCreate={!!(selectedFromField && selectedToField)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 영역 */}
|
||||
<div className="grid max-h-[500px] min-h-[300px] flex-1 grid-cols-2 gap-6 overflow-hidden">
|
||||
{/* FROM 필드 컬럼 */}
|
||||
<div ref={fromColumnRef} className="flex h-full flex-col">
|
||||
<div className="mb-3 flex flex-shrink-0 items-center justify-between">
|
||||
<h3 className="font-medium">FROM 필드</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{filteredFromFields.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3 flex-shrink-0">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={fromSearch}
|
||||
onChange={(e) => setFromSearch(e.target.value)}
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] min-h-0 flex-1">
|
||||
<FieldColumn
|
||||
fields={filteredFromFields}
|
||||
type="from"
|
||||
selectedField={selectedFromField}
|
||||
onFieldSelect={setSelectedFromField}
|
||||
onFieldPositionUpdate={updateFieldPosition}
|
||||
isFieldMapped={isFieldMapped}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
draggedField={draggedField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TO 필드 컬럼 */}
|
||||
<div ref={toColumnRef} className="flex h-full flex-col">
|
||||
<div className="mb-3 flex flex-shrink-0 items-center justify-between">
|
||||
<h3 className="font-medium">TO 필드</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{filteredToFields.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3 flex-shrink-0">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={toSearch}
|
||||
onChange={(e) => setToSearch(e.target.value)}
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] min-h-0 flex-1">
|
||||
<FieldColumn
|
||||
fields={filteredToFields}
|
||||
type="to"
|
||||
selectedField={selectedToField}
|
||||
onFieldSelect={setSelectedToField}
|
||||
onFieldPositionUpdate={updateFieldPosition}
|
||||
isFieldMapped={isFieldMapped}
|
||||
onDrop={handleDrop}
|
||||
isDragOver={isDragOver}
|
||||
draggedField={draggedField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 규칙 안내 */}
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<h4 className="mb-2 text-sm font-medium">📋 매핑 규칙</h4>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>✅ 1:N 매핑 허용 (하나의 소스 필드를 여러 대상 필드에 매핑)</p>
|
||||
<p>❌ N:1 매핑 금지 (여러 소스 필드를 하나의 대상 필드에 매핑 불가)</p>
|
||||
<p>🔒 이미 매핑된 대상 필드는 추가 매핑이 차단됩니다</p>
|
||||
<p>🔗 {mappings.length}개 연결됨</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMappingCanvas;
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link, ArrowRight, MousePointer, Move } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface MappingControlsProps {
|
||||
selectedFromField: ColumnInfo | null;
|
||||
selectedToField: ColumnInfo | null;
|
||||
onCreateMapping: () => void;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 매핑 생성 컨트롤
|
||||
* - 선택된 필드 표시
|
||||
* - 매핑 생성 버튼
|
||||
* - 시각적 피드백
|
||||
*/
|
||||
const MappingControls: React.FC<MappingControlsProps> = ({
|
||||
selectedFromField,
|
||||
selectedToField,
|
||||
onCreateMapping,
|
||||
canCreate,
|
||||
}) => {
|
||||
// 안내 메시지 표시 여부
|
||||
const showGuidance = !selectedFromField && !selectedToField;
|
||||
|
||||
if (showGuidance) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-lg border p-4">
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<MousePointer className="h-4 w-4" />
|
||||
<span>클릭으로 선택</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">또는</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Move className="h-4 w-4" />
|
||||
<span>드래그 앤 드롭으로 매핑</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">선택된 필드:</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{/* FROM 필드 */}
|
||||
<Badge
|
||||
variant={selectedFromField ? "default" : "outline"}
|
||||
className={`transition-all ${selectedFromField ? "shadow-sm" : ""}`}
|
||||
>
|
||||
FROM: {selectedFromField?.displayName || selectedFromField?.columnName || "없음"}
|
||||
</Badge>
|
||||
|
||||
{/* 화살표 */}
|
||||
<ArrowRight
|
||||
className={`h-4 w-4 transition-colors ${canCreate ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* TO 필드 */}
|
||||
<Badge
|
||||
variant={selectedToField ? "default" : "outline"}
|
||||
className={`transition-all ${selectedToField ? "shadow-sm" : ""}`}
|
||||
>
|
||||
TO: {selectedToField?.displayName || selectedToField?.columnName || "없음"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입 호환성 표시 */}
|
||||
{selectedFromField && selectedToField && (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">타입:</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedFromField.webType || "unknown"}
|
||||
</Badge>
|
||||
<ArrowRight className="text-muted-foreground h-3 w-3" />
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedToField.webType || "unknown"}
|
||||
</Badge>
|
||||
{/* 타입 호환성 아이콘 */}
|
||||
{selectedFromField.webType === selectedToField.webType ? (
|
||||
<span className="text-xs text-green-600">✅</span>
|
||||
) : (
|
||||
<span className="text-xs text-orange-600">⚠️</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 생성 버튼 */}
|
||||
<Button
|
||||
onClick={onCreateMapping}
|
||||
disabled={!canCreate}
|
||||
size="sm"
|
||||
className={`transition-all ${canCreate ? "shadow-sm hover:shadow-md" : ""}`}
|
||||
>
|
||||
<Link className="mr-1 h-4 w-4" />
|
||||
매핑 생성
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MappingControls;
|
||||
Reference in New Issue
Block a user