관계도 조회 구현

This commit is contained in:
hyeonsu 2025-09-09 12:00:58 +09:00
parent 7260ad733b
commit 3a24fd3ebd
3 changed files with 199 additions and 94 deletions

View File

@ -62,7 +62,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode,
onSave,
selectedDiagram,
onBackToList,
onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars
}) => {
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@ -84,7 +84,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
};
} | null>(null);
const [relationships, setRelationships] = useState<TableRelationship[]>([]); // eslint-disable-line @typescript-eslint/no-unused-vars
const toastShownRef = useRef(false);
const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars
// 키보드 이벤트 핸들러 (Del 키로 선택된 노드 삭제)
useEffect(() => {
@ -212,6 +212,38 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
}
}
// 연결된 컬럼 정보 계산
const connectedColumnsInfo: {
[tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } };
} = {};
diagramRelationships.forEach((rel) => {
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name.split(",").map((col) => col.trim());
const toColumns = rel.to_column_name.split(",").map((col) => col.trim());
// 소스 테이블의 컬럼들을 source로 표시
if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {};
fromColumns.forEach((col) => {
if (connectedColumnsInfo[fromTable][col]) {
connectedColumnsInfo[fromTable][col].direction = "both";
} else {
connectedColumnsInfo[fromTable][col] = { direction: "source" };
}
});
// 타겟 테이블의 컬럼들을 target으로 표시
if (!connectedColumnsInfo[toTable]) connectedColumnsInfo[toTable] = {};
toColumns.forEach((col) => {
if (connectedColumnsInfo[toTable][col]) {
connectedColumnsInfo[toTable][col].direction = "both";
} else {
connectedColumnsInfo[toTable][col] = { direction: "target" };
}
});
});
// 테이블을 노드로 변환 (자동 레이아웃)
const tableNodes = tableDefinitions.map((table, index) => {
const x = (index % 3) * 400 + 100; // 3열 배치
@ -234,6 +266,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
},
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[table.tableName] || [],
connectedColumns: connectedColumnsInfo[table.tableName] || {},
} as TableNodeData,
};
});
@ -242,23 +275,47 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
console.log("📍 테이블 노드 상세:", tableNodes);
setNodes(tableNodes);
// 관계를 엣지로 변환하여 표시
const relationshipEdges = diagramRelationships.map((rel) => ({
id: generateUniqueId("edge", rel.relationship_id),
source: `table-${rel.from_table_name}`,
target: `table-${rel.to_table_name}`,
sourceHandle: "right",
targetHandle: "left",
type: "default",
data: {
relationshipId: rel.relationship_id,
relationshipType: rel.relationship_type,
connectionType: rel.connection_type,
label: rel.relationship_name,
fromColumn: rel.from_column_name,
toColumn: rel.to_column_name,
},
}));
// 관계를 엣지로 변환하여 표시 (컬럼별 연결)
const relationshipEdges: Edge[] = [];
diagramRelationships.forEach((rel) => {
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name.split(",").map((col) => col.trim());
const toColumns = rel.to_column_name.split(",").map((col) => col.trim());
// 각 from 컬럼을 각 to 컬럼에 연결 (1:1 매핑이거나 many:many인 경우)
const maxConnections = Math.max(fromColumns.length, toColumns.length);
for (let i = 0; i < maxConnections; i++) {
const fromColumn = fromColumns[i] || fromColumns[0]; // 컬럼이 부족하면 첫 번째 컬럼 재사용
const toColumn = toColumns[i] || toColumns[0]; // 컬럼이 부족하면 첫 번째 컬럼 재사용
relationshipEdges.push({
id: generateUniqueId("edge", rel.relationship_id),
source: `table-${fromTable}`,
target: `table-${toTable}`,
sourceHandle: `${fromTable}-${fromColumn}-source`,
targetHandle: `${toTable}-${toColumn}-target`,
type: "smoothstep",
animated: false,
style: {
stroke: "#3b82f6",
strokeWidth: 1.5,
strokeDasharray: "none",
},
data: {
relationshipId: rel.relationship_id,
relationshipType: rel.relationship_type,
connectionType: rel.connection_type,
fromTable: fromTable,
toTable: toTable,
fromColumn: fromColumn,
toColumn: toColumn,
},
});
}
});
console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length);
console.log("📍 관계 에지 상세:", relationshipEdges);
@ -268,7 +325,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
console.error("선택된 관계도 로드 실패:", error);
toast.error("관계도를 불러오는데 실패했습니다.", { id: "load-diagram" });
}
}, [selectedDiagram, companyCode, setNodes, setEdges, selectedColumns, handleColumnClick]);
}, [selectedDiagram, setNodes, setEdges, selectedColumns, handleColumnClick]);
// 기존 관계 로드 (새 관계도 생성 시)
const loadExistingRelationships = useCallback(async () => {
@ -278,23 +335,47 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode);
setRelationships(existingRelationships);
// 기존 관계를 엣지로 변환하여 표시
const existingEdges = existingRelationships.map((rel) => ({
id: generateUniqueId("edge", rel.relationship_id),
source: `table-${rel.from_table_name}`,
target: `table-${rel.to_table_name}`,
sourceHandle: "right",
targetHandle: "left",
type: "default",
data: {
relationshipId: rel.relationship_id,
relationshipType: rel.relationship_type,
connectionType: rel.connection_type,
label: rel.relationship_name,
fromColumn: rel.from_column_name,
toColumn: rel.to_column_name,
},
}));
// 기존 관계를 엣지로 변환하여 표시 (컬럼별 연결)
const existingEdges: Edge[] = [];
existingRelationships.forEach((rel) => {
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name.split(",").map((col) => col.trim());
const toColumns = rel.to_column_name.split(",").map((col) => col.trim());
// 각 from 컬럼을 각 to 컬럼에 연결
const maxConnections = Math.max(fromColumns.length, toColumns.length);
for (let i = 0; i < maxConnections; i++) {
const fromColumn = fromColumns[i] || fromColumns[0];
const toColumn = toColumns[i] || toColumns[0];
existingEdges.push({
id: generateUniqueId("edge", rel.relationship_id),
source: `table-${fromTable}`,
target: `table-${toTable}`,
sourceHandle: `${fromTable}-${fromColumn}-source`,
targetHandle: `${toTable}-${toColumn}-target`,
type: "smoothstep",
animated: false,
style: {
stroke: "#3b82f6",
strokeWidth: 1.5,
strokeDasharray: "none",
},
data: {
relationshipId: rel.relationship_id,
relationshipType: rel.relationship_type,
connectionType: rel.connection_type,
fromTable: fromTable,
toTable: toTable,
fromColumn: fromColumn,
toColumn: toColumn,
},
});
}
});
setEdges(existingEdges);
} catch (error) {
@ -488,24 +569,45 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
(relationship: TableRelationship) => {
if (!pendingConnection) return;
const newEdge = {
id: generateUniqueId("edge", relationship.relationship_id),
source: pendingConnection.fromNode.id,
target: pendingConnection.toNode.id,
sourceHandle: "right",
targetHandle: "left",
type: "default",
data: {
relationshipId: relationship.relationship_id,
relationshipType: relationship.relationship_type,
connectionType: relationship.connection_type,
label: relationship.relationship_name,
fromColumn: relationship.from_column_name,
toColumn: relationship.to_column_name,
},
};
// 컬럼별 에지 생성
const newEdges: Edge[] = [];
const fromTable = relationship.from_table_name;
const toTable = relationship.to_table_name;
const fromColumns = relationship.from_column_name.split(",").map((col) => col.trim());
const toColumns = relationship.to_column_name.split(",").map((col) => col.trim());
setEdges((eds) => [...eds, newEdge]);
const maxConnections = Math.max(fromColumns.length, toColumns.length);
for (let i = 0; i < maxConnections; i++) {
const fromColumn = fromColumns[i] || fromColumns[0];
const toColumn = toColumns[i] || toColumns[0];
newEdges.push({
id: generateUniqueId("edge", relationship.relationship_id),
source: pendingConnection.fromNode.id,
target: pendingConnection.toNode.id,
sourceHandle: `${fromTable}-${fromColumn}-source`,
targetHandle: `${toTable}-${toColumn}-target`,
type: "smoothstep",
animated: false,
style: {
stroke: "#3b82f6",
strokeWidth: 1.5,
strokeDasharray: "none",
},
data: {
relationshipId: relationship.relationship_id,
relationshipType: relationship.relationship_type,
connectionType: relationship.connection_type,
fromTable: fromTable,
toTable: toTable,
fromColumn: fromColumn,
toColumn: toColumn,
},
});
}
setEdges((eds) => [...eds, ...newEdges]);
setRelationships((prev) => [...prev, relationship]);
setPendingConnection(null);

View File

@ -12,7 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
import { toast } from "sonner";
@ -58,31 +58,10 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
};
}, [currentPage, searchTerm]);
// 관계도 목록 다시 로드
const reloadDiagrams = async () => {
try {
setLoading(true);
const response = await DataFlowAPI.getDataFlowDiagrams(currentPage, 20, searchTerm);
setDiagrams(response.diagrams || []);
setTotal(response.total || 0);
setTotalPages(Math.max(1, Math.ceil((response.total || 0) / 20)));
} catch (error) {
console.error("관계도 목록 조회 실패", error);
toast.error("관계도 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
const handleDiagramSelect = (diagram: DataFlowDiagram) => {
onDiagramSelect(diagram);
};
const handleEdit = (diagram: DataFlowDiagram) => {
// 편집 모달 열기
console.log("편집:", diagram);
};
const handleDelete = (diagram: DataFlowDiagram) => {
if (confirm(`"${diagram.diagramName}" 관계도를 삭제하시겠습니까?`)) {
// 삭제 API 호출
@ -96,12 +75,6 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
toast.info("복사 기능은 아직 구현되지 않았습니다.");
};
const handleView = (diagram: DataFlowDiagram) => {
// 미리보기 모달 열기
console.log("미리보기:", diagram);
toast.info("미리보기 기능은 아직 구현되지 않았습니다.");
};
// 연결 타입에 따른 배지 색상
const getConnectionTypeBadge = (connectionType: string) => {
switch (connectionType) {
@ -268,14 +241,6 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
<Network className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleView(diagram)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(diagram)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
<Copy className="mr-2 h-4 w-4" />

View File

@ -22,10 +22,18 @@ interface TableNodeData {
onScrollAreaEnter?: () => void;
onScrollAreaLeave?: () => void;
selectedColumns?: string[]; // 선택된 컬럼 목록
connectedColumns?: { [columnName: string]: { direction: "source" | "target" | "both" } }; // 연결된 컬럼 정보
}
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { table, onColumnClick, onScrollAreaEnter, onScrollAreaLeave, selectedColumns = [] } = data;
const {
table,
onColumnClick,
onScrollAreaEnter,
onScrollAreaLeave,
selectedColumns = [],
connectedColumns = {},
} = data;
return (
<div className="relative flex min-w-[280px] flex-col overflow-hidden rounded-lg border-2 border-gray-300 bg-white shadow-lg">
@ -42,17 +50,47 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 컬럼 목록 */}
<div className="flex-1 overflow-hidden p-2" onMouseEnter={onScrollAreaEnter} onMouseLeave={onScrollAreaLeave}>
<div className="space-y-1">
{table.columns.map((column) => {
{table.columns.map((column, index) => {
const isSelected = selectedColumns.includes(column.name);
const connectionInfo = connectedColumns[column.name];
const isConnected = !!connectionInfo;
// 연결된 컬럼에만 핸들 표시
const showSourceHandle =
isConnected && (connectionInfo.direction === "source" || connectionInfo.direction === "both");
const showTargetHandle =
isConnected && (connectionInfo.direction === "target" || connectionInfo.direction === "both");
return (
<div
key={column.name}
onClick={() => onColumnClick(table.tableName, column.name)}
className={`cursor-pointer rounded px-2 py-1 text-xs transition-colors ${
className={`relative cursor-pointer rounded px-2 py-1 text-xs transition-colors ${
isSelected ? "bg-blue-100 text-blue-800 ring-2 ring-blue-500" : "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => onColumnClick(table.tableName, column.name)}
>
{/* Target Handle (왼쪽) - 세련된 디자인 */}
{showTargetHandle && (
<Handle
type="target"
position={Position.Left}
id={`${table.tableName}-${column.name}-target`}
className="!absolute !left-[-6px] !h-2 !w-2 !rounded-full !border-0 !bg-blue-500 !shadow-sm hover:!bg-blue-600 hover:!shadow-md"
style={{ top: "50%", transform: "translateY(-50%)" }}
/>
)}
{/* Source Handle (오른쪽) - 세련된 디자인 */}
{showSourceHandle && (
<Handle
type="source"
position={Position.Right}
id={`${table.tableName}-${column.name}-source`}
className="!absolute !right-[-6px] !h-2 !w-2 !rounded-full !border-0 !bg-blue-500 !shadow-sm hover:!bg-blue-600 hover:!shadow-md"
style={{ top: "50%", transform: "translateY(-50%)" }}
/>
)}
<div className="flex items-center justify-between">
<span className="font-mono font-medium">{column.name}</span>
<span className="text-gray-500">{column.type}</span>