195 lines
6.5 KiB
TypeScript
195 lines
6.5 KiB
TypeScript
"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-destructive/10 shadow-md"
|
|
: isDropTarget
|
|
? "border-blue-400 bg-accent 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;
|