2025-09-26 01:28:51 +09:00
|
|
|
"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") => {
|
2025-09-26 13:52:32 +09:00
|
|
|
return mappings
|
|
|
|
|
.filter((mapping) => mapping.fromField && mapping.toField) // 유효한 매핑만 확인
|
|
|
|
|
.some((mapping) =>
|
|
|
|
|
type === "from"
|
|
|
|
|
? mapping.fromField?.columnName === field.columnName
|
|
|
|
|
: mapping.toField?.columnName === field.columnName,
|
|
|
|
|
);
|
2025-09-26 01:28:51 +09:00
|
|
|
},
|
|
|
|
|
[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;
|