생성된 관계도 확인

This commit is contained in:
hyeonsu
2025-09-09 11:35:05 +09:00
parent 989c118ad2
commit 7260ad733b
8 changed files with 1018 additions and 107 deletions

View File

@@ -188,15 +188,15 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
toast.loading("관계를 생성하고 있습니다...", { id: "create-relationship" });
// 단일 관계 데이터 준비 (모든 선택된 컬럼 정보 포함)
const relationshipData: Omit<TableRelationship, "relationshipId"> = {
relationshipName: config.relationshipName,
fromTableName: connection.fromNode.tableName,
fromColumnName: fromColumns.join(","), // 여러 컬럼을 콤마로 구분
toTableName: connection.toNode.tableName,
toColumnName: toColumns.join(","), // 여러 컬럼을 콤마로 구분
relationshipType: config.relationshipType,
connectionType: config.connectionType,
companyCode: companyCode,
const relationshipData: Omit<TableRelationship, "relationship_id"> = {
relationship_name: config.relationshipName,
from_table_name: connection.fromNode.tableName,
from_column_name: fromColumns.join(","), // 여러 컬럼을 콤마로 구분
to_table_name: connection.toNode.tableName,
to_column_name: toColumns.join(","), // 여러 컬럼을 콤마로 구분
relationship_type: config.relationshipType,
connection_type: config.connectionType,
company_code: companyCode,
settings: {
...settings,
multiColumnMapping: {
@@ -211,7 +211,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
to: toColumns.length,
},
},
isActive: "Y",
is_active: "Y",
};
// API 호출

View File

@@ -17,7 +17,7 @@ import "@xyflow/react/dist/style.css";
import { TableNode } from "./TableNode";
import { TableSelector } from "./TableSelector";
import { ConnectionSetupModal } from "./ConnectionSetupModal";
import { TableDefinition, TableRelationship, DataFlowAPI } from "@/lib/api/dataflow";
import { TableDefinition, TableRelationship, DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
// 고유 ID 생성 함수
const generateUniqueId = (prefix: string, relationshipId?: number): string => {
@@ -52,11 +52,18 @@ const edgeTypes = {};
interface DataFlowDesignerProps {
companyCode: string;
onSave?: (relationships: TableRelationship[]) => void;
selectedDiagram?: DataFlowDiagram | null;
onBackToList?: () => void;
}
// TableRelationship 타입은 dataflow.ts에서 import
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode, onSave }) => {
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode,
onSave,
selectedDiagram,
onBackToList,
}) => {
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selectedColumns, setSelectedColumns] = useState<{
@@ -113,56 +120,6 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodes, setNodes]);
// 기존 관계 로드
const loadExistingRelationships = useCallback(async () => {
try {
const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode);
setRelationships(existingRelationships);
// 기존 관계를 엣지로 변환하여 표시
const existingEdges = existingRelationships.map((rel) => ({
id: generateUniqueId("edge", rel.relationshipId),
source: `table-${rel.fromTableName}`,
target: `table-${rel.toTableName}`,
sourceHandle: "right",
targetHandle: "left",
type: "default",
data: {
relationshipId: rel.relationshipId,
relationshipType: rel.relationshipType,
connectionType: rel.connectionType,
label: rel.relationshipName,
fromColumn: rel.fromColumnName,
toColumn: rel.toColumnName,
},
}));
setEdges(existingEdges);
} catch (error) {
console.error("기존 관계 로드 실패:", error);
toast.error("기존 관계를 불러오는데 실패했습니다.");
}
}, [companyCode, setEdges]);
// 컴포넌트 마운트 시 기존 관계 로드
useEffect(() => {
if (companyCode) {
loadExistingRelationships();
}
}, [companyCode, loadExistingRelationships]);
// 노드 선택 변경 핸들러
const onSelectionChange = useCallback(({ nodes }: { nodes: Node<TableNodeData>[] }) => {
const selectedNodeIds = nodes.map((node) => node.id);
setSelectedNodes(selectedNodeIds);
}, []);
// 빈 onConnect 함수 (드래그 연결 비활성화)
const onConnect = useCallback(() => {
// 드래그로 연결하는 것을 방지
return;
}, []);
// 컬럼 클릭 처리 (토글 방식, 최대 2개 테이블만 허용)
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
setSelectedColumns((prev) => {
@@ -184,39 +141,191 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
}
return { ...prev, [tableName]: newColumns };
} else {
// 선택 추가 - 새로운 테이블이고 이미 2개 테이블이 선택되어 있으면 거부
if (!prev[tableName] && selectedTables.length >= 2) {
// 토스트 중복 방지를 위한 ref 사용
if (!toastShownRef.current) {
toastShownRef.current = true;
setTimeout(() => {
toast.error("최대 2개의 테이블에서만 컬럼을 선택할 수 있습니다.", {
duration: 3000,
position: "top-center",
});
// 3초 후 플래그 리셋
setTimeout(() => {
toastShownRef.current = false;
}, 3000);
}, 0);
}
// 선택
if (selectedTables.length >= 2 && !selectedTables.includes(tableName)) {
toast.error("최대 2개 테이블까지만 선택할 수 있습니다.");
return prev;
}
// 새로운 테이블이면 선택 순서에 추가, 기존 테이블이면 맨 뒤로 이동 (다음 렌더링에서)
const newColumns = [...currentColumns, columnName];
const newSelection = { ...prev, [tableName]: newColumns };
// 선택 순서 업데이트 (다음 렌더링에서)
setTimeout(() => {
setSelectionOrder((order) => {
// 기존에 있던 테이블이면 제거 후 맨 뒤에 추가 (순서 갱신)
const filteredOrder = order.filter((name) => name !== tableName);
return [...filteredOrder, tableName];
if (!order.includes(tableName)) {
return [...order, tableName];
}
return order;
});
}, 0);
return { ...prev, [tableName]: [...currentColumns, columnName] };
return newSelection;
}
});
}, []);
// 선택된 관계도의 관계 로드
const loadSelectedDiagramRelationships = useCallback(async () => {
if (!selectedDiagram) return;
try {
console.log("🔍 관계도 로드 시작:", selectedDiagram.diagramName);
toast.loading("관계도를 불러오는 중...", { id: "load-diagram" });
const diagramRelationships = await DataFlowAPI.getDiagramRelationships(selectedDiagram.diagramName);
console.log("📋 관계도 관계 데이터:", diagramRelationships);
console.log("📋 첫 번째 관계 상세:", diagramRelationships[0]);
console.log(
"📋 관계 객체 키들:",
diagramRelationships[0] ? Object.keys(diagramRelationships[0]) : "배열이 비어있음",
);
setRelationships(diagramRelationships);
// 관계도의 모든 테이블 추출
const tableNames = new Set<string>();
diagramRelationships.forEach((rel) => {
tableNames.add(rel.from_table_name);
tableNames.add(rel.to_table_name);
});
console.log("📊 추출된 테이블 이름들:", Array.from(tableNames));
// 테이블 정보 로드
const allTables = await DataFlowAPI.getTables();
console.log("🏢 전체 테이블 수:", allTables.length);
const tableDefinitions: TableDefinition[] = [];
for (const tableName of tableNames) {
const foundTable = allTables.find((t) => t.tableName === tableName);
console.log(`🔍 테이블 ${tableName} 검색 결과:`, foundTable);
if (foundTable) {
// 각 테이블의 컬럼 정보를 별도로 가져옴
const columns = await DataFlowAPI.getTableColumns(tableName);
console.log(`📋 테이블 ${tableName}의 컬럼 수:`, columns.length);
tableDefinitions.push({
tableName: foundTable.tableName,
displayName: foundTable.displayName,
description: foundTable.description,
columns: columns,
});
} else {
console.warn(`⚠️ 테이블 ${tableName}을 찾을 수 없습니다`);
}
}
// 테이블을 노드로 변환 (자동 레이아웃)
const tableNodes = tableDefinitions.map((table, index) => {
const x = (index % 3) * 400 + 100; // 3열 배치
const y = Math.floor(index / 3) * 300 + 100;
return {
id: `table-${table.tableName}`,
type: "tableNode",
position: { x, y },
data: {
table: {
tableName: table.tableName,
displayName: table.displayName,
description: table.description || "",
columns: table.columns.map((col) => ({
name: col.columnName,
type: col.dataType || "varchar",
description: col.description || "",
})),
},
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[table.tableName] || [],
} as TableNodeData,
};
});
console.log("🎨 생성된 테이블 노드 수:", tableNodes.length);
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,
},
}));
console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length);
console.log("📍 관계 에지 상세:", relationshipEdges);
setEdges(relationshipEdges);
toast.success(`"${selectedDiagram.diagramName}" 관계도를 불러왔습니다.`, { id: "load-diagram" });
} catch (error) {
console.error("선택된 관계도 로드 실패:", error);
toast.error("관계도를 불러오는데 실패했습니다.", { id: "load-diagram" });
}
}, [selectedDiagram, companyCode, setNodes, setEdges, selectedColumns, handleColumnClick]);
// 기존 관계 로드 (새 관계도 생성 시)
const loadExistingRelationships = useCallback(async () => {
if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음
try {
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,
},
}));
setEdges(existingEdges);
} catch (error) {
console.error("기존 관계 로드 실패:", error);
toast.error("기존 관계를 불러오는데 실패했습니다.");
}
}, [companyCode, setEdges, selectedDiagram]);
// 컴포넌트 마운트 시 관계 로드
useEffect(() => {
if (companyCode) {
if (selectedDiagram) {
loadSelectedDiagramRelationships();
} else {
loadExistingRelationships();
}
}
}, [companyCode, selectedDiagram, loadExistingRelationships, loadSelectedDiagramRelationships]);
// 노드 선택 변경 핸들러
const onSelectionChange = useCallback(({ nodes }: { nodes: Node<TableNodeData>[] }) => {
const selectedNodeIds = nodes.map((node) => node.id);
setSelectedNodes(selectedNodeIds);
}, []);
// 빈 onConnect 함수 (드래그 연결 비활성화)
const onConnect = useCallback(() => {
// 드래그로 연결하는 것을 방지
return;
}, []);
// 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
useEffect(() => {
setNodes((prevNodes) =>
@@ -380,19 +489,19 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
if (!pendingConnection) return;
const newEdge = {
id: generateUniqueId("edge", relationship.relationshipId),
id: generateUniqueId("edge", relationship.relationship_id),
source: pendingConnection.fromNode.id,
target: pendingConnection.toNode.id,
sourceHandle: "right",
targetHandle: "left",
type: "default",
data: {
relationshipId: relationship.relationshipId,
relationshipType: relationship.relationshipType,
connectionType: relationship.connectionType,
label: relationship.relationshipName,
fromColumn: relationship.fromColumnName,
toColumn: relationship.toColumnName,
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,
},
};

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
import { toast } from "sonner";
interface DataFlowListProps {
onDiagramSelect: (diagram: DataFlowDiagram) => void;
selectedDiagram: DataFlowDiagram | null;
onDesignDiagram: (diagram: DataFlowDiagram | null) => void;
}
export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesignDiagram }: DataFlowListProps) {
const [diagrams, setDiagrams] = useState<DataFlowDiagram[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// 관계도 목록 로드
useEffect(() => {
let abort = false;
const load = async () => {
try {
setLoading(true);
const response = await DataFlowAPI.getDataFlowDiagrams(currentPage, 20, searchTerm);
if (abort) return;
setDiagrams(response.diagrams || []);
setTotal(response.total || 0);
setTotalPages(Math.max(1, Math.ceil((response.total || 0) / 20)));
} catch (error) {
console.error("관계도 목록 조회 실패", error);
setDiagrams([]);
setTotalPages(1);
setTotal(0);
toast.error("관계도 목록을 불러오는데 실패했습니다.");
} finally {
if (!abort) setLoading(false);
}
};
load();
return () => {
abort = true;
};
}, [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 호출
console.log("삭제:", diagram);
toast.info("삭제 기능은 아직 구현되지 않았습니다.");
}
};
const handleCopy = (diagram: DataFlowDiagram) => {
console.log("복사:", diagram);
toast.info("복사 기능은 아직 구현되지 않았습니다.");
};
const handleView = (diagram: DataFlowDiagram) => {
// 미리보기 모달 열기
console.log("미리보기:", diagram);
toast.info("미리보기 기능은 아직 구현되지 않았습니다.");
};
// 연결 타입에 따른 배지 색상
const getConnectionTypeBadge = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return (
<Badge variant="outline" className="border-blue-200 bg-blue-50 text-blue-700">
</Badge>
);
case "data-save":
return (
<Badge variant="outline" className="border-green-200 bg-green-50 text-green-700">
</Badge>
);
case "external-call":
return (
<Badge variant="outline" className="border-purple-200 bg-purple-50 text-purple-700">
</Badge>
);
default:
return <Badge variant="outline">{connectionType}</Badge>;
}
};
// 관계 타입에 따른 배지 색상
const getRelationshipTypeBadge = (relationshipType: string) => {
switch (relationshipType) {
case "one-to-one":
return (
<Badge variant="secondary" className="bg-gray-100 text-gray-700">
1:1
</Badge>
);
case "one-to-many":
return (
<Badge variant="secondary" className="bg-orange-100 text-orange-700">
1:N
</Badge>
);
case "many-to-one":
return (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-700">
N:1
</Badge>
);
case "many-to-many":
return (
<Badge variant="secondary" className="bg-red-100 text-red-700">
N:N
</Badge>
);
default:
return <Badge variant="secondary">{relationshipType}</Badge>;
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="관계도명, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
/>
</div>
</div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 관계도 목록 테이블 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center">
<Network className="mr-2 h-5 w-5" />
({total})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diagrams.map((diagram) => (
<TableRow
key={diagram.diagramName}
className={`cursor-pointer hover:bg-gray-50 ${
selectedDiagram?.diagramName === diagram.diagramName ? "border-blue-200 bg-blue-50" : ""
}`}
onClick={() => handleDiagramSelect(diagram)}
>
<TableCell>
<div>
<div className="flex items-center font-medium text-gray-900">
<Database className="mr-2 h-4 w-4 text-gray-500" />
{diagram.diagramName}
</div>
<div className="mt-1 text-sm text-gray-500">
: {diagram.tables.slice(0, 3).join(", ")}
{diagram.tables.length > 3 && `${diagram.tables.length - 3}`}
</div>
</div>
</TableCell>
<TableCell>{getConnectionTypeBadge(diagram.connectionType)}</TableCell>
<TableCell>{getRelationshipTypeBadge(diagram.relationshipType)}</TableCell>
<TableCell>
<div className="flex items-center">
<Database className="mr-1 h-3 w-3 text-gray-400" />
{diagram.tableCount}
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
<Network className="mr-1 h-3 w-3 text-gray-400" />
{diagram.relationshipCount}
</div>
</TableCell>
<TableCell>
<div className="flex items-center text-sm text-gray-600">
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
{new Date(diagram.updatedAt).toLocaleDateString()}
</div>
<div className="flex items-center text-xs text-gray-400">
<User className="mr-1 h-3 w-3" />
{diagram.updatedBy}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
<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" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{diagrams.length === 0 && (
<div className="py-8 text-center text-gray-500">
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> .</div>
</div>
)}
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-600">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</div>
);
}