테이블 기반 방식으로 변경

This commit is contained in:
hyeonsu
2025-09-05 18:00:18 +09:00
parent b844719da4
commit f74442dce5
10 changed files with 756 additions and 812 deletions

View File

@@ -11,51 +11,75 @@ import {
MiniMap,
useNodesState,
useEdgesState,
addEdge,
Connection,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ScreenNode } from "./ScreenNode";
import { CustomEdge } from "./CustomEdge";
import { ScreenSelector } from "./ScreenSelector";
import { TableNode } from "./TableNode";
import { TableSelector } from "./TableSelector";
import { ConnectionSetupModal } from "./ConnectionSetupModal";
import { DataFlowAPI, ScreenDefinition, ColumnInfo, ScreenWithFields } from "@/lib/api/dataflow";
import { TableDefinition } from "@/lib/api/dataflow";
// 테이블 노드 데이터 타입 정의
interface TableNodeData extends Record<string, unknown> {
table: {
tableName: string;
displayName: string;
description: string;
columns: Array<{
name: string;
type: string;
description: string;
}>;
};
onColumnClick: (tableName: string, columnName: string) => void;
selectedColumns: string[];
}
// 노드 및 엣지 타입 정의
const nodeTypes = {
screenNode: ScreenNode,
tableNode: TableNode,
};
const edgeTypes = {
customEdge: CustomEdge,
};
const edgeTypes = {};
interface DataFlowDesignerProps {
companyCode: string;
onSave?: (relationships: any[]) => void;
onSave?: (relationships: TableRelationship[]) => void;
}
interface TableRelationship {
relationshipId?: number;
relationshipName: string;
fromTableName: string;
fromColumnName: string;
toTableName: string;
toColumnName: string;
relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
connectionType: "simple-key" | "data-save" | "external-call";
settings?: Record<string, unknown>;
companyCode: string;
isActive?: string;
}
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode, onSave }) => {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selectedFields, setSelectedFields] = useState<{
[screenId: string]: string[];
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selectedColumns, setSelectedColumns] = useState<{
[tableName: string]: string[];
}>({});
const [selectionOrder, setSelectionOrder] = useState<string[]>([]);
const [loadingScreens, setLoadingScreens] = useState<Set<number>>(new Set());
const [pendingConnection, setPendingConnection] = useState<{
fromNode: { id: string; screenName: string; tableName: string };
toNode: { id: string; screenName: string; tableName: string };
fromField?: string;
toField?: string;
selectedFieldsData?: {
[screenId: string]: {
screenName: string;
fields: string[];
fromNode: { id: string; tableName: string; displayName: string };
toNode: { id: string; tableName: string; displayName: string };
fromColumn?: string;
toColumn?: string;
selectedColumnsData?: {
[tableName: string]: {
displayName: string;
columns: string[];
};
};
} | null>(null);
const [isOverNodeScrollArea, setIsOverNodeScrollArea] = useState(false);
const toastShownRef = useRef(false);
// 빈 onConnect 함수 (드래그 연결 비활성화)
@@ -64,33 +88,34 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
return;
}, []);
// 필드 클릭 처리 (토글 방식, 최대 2개 화면만 허용)
const handleFieldClick = useCallback((screenId: string, fieldName: string) => {
setSelectedFields((prev) => {
const currentFields = prev[screenId] || [];
const isSelected = currentFields.includes(fieldName);
const selectedScreens = Object.keys(prev).filter((id) => prev[id] && prev[id].length > 0);
// 컬럼 클릭 처리 (토글 방식, 최대 2개 테이블만 허용)
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
setSelectedColumns((prev) => {
const currentColumns = prev[tableName] || [];
const isSelected = currentColumns.includes(columnName);
const selectedTables = Object.keys(prev).filter((name) => prev[name] && prev[name].length > 0);
if (isSelected) {
// 선택 해제
const newFields = currentFields.filter((field) => field !== fieldName);
if (newFields.length === 0) {
const { [screenId]: _, ...rest } = prev;
const newColumns = currentColumns.filter((column) => column !== columnName);
if (newColumns.length === 0) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [tableName]: removed, ...rest } = prev;
// 선택 순서에서도 제거 (다음 렌더링에서)
setTimeout(() => {
setSelectionOrder((order) => order.filter((id) => id !== screenId));
setSelectionOrder((order) => order.filter((name) => name !== tableName));
}, 0);
return rest;
}
return { ...prev, [screenId]: newFields };
return { ...prev, [tableName]: newColumns };
} else {
// 선택 추가 - 새로운 화면이고 이미 2개 화면이 선택되어 있으면 거부
if (!prev[screenId] && selectedScreens.length >= 2) {
// 선택 추가 - 새로운 테이블이고 이미 2개 테이블이 선택되어 있으면 거부
if (!prev[tableName] && selectedTables.length >= 2) {
// 토스트 중복 방지를 위한 ref 사용
if (!toastShownRef.current) {
toastShownRef.current = true;
setTimeout(() => {
toast.error("최대 2개의 화면에서만 필드를 선택할 수 있습니다.", {
toast.error("최대 2개의 테이블에서만 컬럼을 선택할 수 있습니다.", {
duration: 3000,
position: "top-center",
});
@@ -103,195 +128,179 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
return prev;
}
// 새로운 화면이면 선택 순서에 추가, 기존 화면이면 맨 뒤로 이동 (다음 렌더링에서)
// 새로운 테이블이면 선택 순서에 추가, 기존 테이블이면 맨 뒤로 이동 (다음 렌더링에서)
setTimeout(() => {
setSelectionOrder((order) => {
// 기존에 있던 화면이면 제거 후 맨 뒤에 추가 (순서 갱신)
const filteredOrder = order.filter((id) => id !== screenId);
return [...filteredOrder, screenId];
// 기존에 있던 테이블이면 제거 후 맨 뒤에 추가 (순서 갱신)
const filteredOrder = order.filter((name) => name !== tableName);
return [...filteredOrder, tableName];
});
}, 0);
return { ...prev, [screenId]: [...currentFields, fieldName] };
return { ...prev, [tableName]: [...currentColumns, columnName] };
}
});
}, []);
// 선택된 필드가 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
// 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
useEffect(() => {
setNodes((prevNodes) =>
prevNodes.map((node) => ({
...node,
data: {
...node.data,
selectedFields: selectedFields[node.data.screen.screenId] || [],
selectedColumns: selectedColumns[node.data.table.tableName] || [],
},
})),
);
// selectionOrder에서 선택되지 않은 화면들 제거
const activeScreens = Object.keys(selectedFields).filter(
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
// selectionOrder에서 선택되지 않은 테이블들 제거
const activeTables = Object.keys(selectedColumns).filter(
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
);
setSelectionOrder((prev) => prev.filter((screenId) => activeScreens.includes(screenId)));
}, [selectedFields, setNodes]);
setSelectionOrder((prev) => prev.filter((tableName) => activeTables.includes(tableName)));
}, [selectedColumns, setNodes]);
// 연결 가능한 상태인지 확인
const canCreateConnection = () => {
const selectedScreens = Object.keys(selectedFields).filter(
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
const selectedTables = Object.keys(selectedColumns).filter(
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
);
// 최소 2개의 서로 다른 테이블에서 필드가 선택되어야 함
return selectedScreens.length >= 2;
// 최소 2개의 서로 다른 테이블에서 컬럼이 선택되어야 함
return selectedTables.length >= 2;
};
// 필드 연결 설정 모달 열기
// 컬럼 연결 설정 모달 열기
const openConnectionModal = () => {
const selectedScreens = Object.keys(selectedFields).filter(
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
const selectedTables = Object.keys(selectedColumns).filter(
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
);
if (selectedScreens.length < 2) return;
if (selectedTables.length < 2) return;
// 선택 순서에 따라 첫 번째와 두 번째 화면 설정
const orderedScreens = selectionOrder.filter((id) => selectedScreens.includes(id));
const firstScreenId = orderedScreens[0];
const secondScreenId = orderedScreens[1];
const firstNode = nodes.find((node) => node.data.screen.screenId === firstScreenId);
const secondNode = nodes.find((node) => node.data.screen.screenId === secondScreenId);
// 선택 순서에 따라 첫 번째와 두 번째 테이블 설정
const orderedTables = selectionOrder.filter((name) => selectedTables.includes(name));
const firstTableName = orderedTables[0];
const secondTableName = orderedTables[1];
const firstNode = nodes.find((node) => node.data.table.tableName === firstTableName);
const secondNode = nodes.find((node) => node.data.table.tableName === secondTableName);
if (!firstNode || !secondNode) return;
setPendingConnection({
fromNode: {
id: firstNode.id,
screenName: firstNode.data.screen.screenName,
tableName: firstNode.data.screen.tableName,
tableName: firstNode.data.table.tableName,
displayName: firstNode.data.table.displayName,
},
toNode: {
id: secondNode.id,
screenName: secondNode.data.screen.screenName,
tableName: secondNode.data.screen.tableName,
tableName: secondNode.data.table.tableName,
displayName: secondNode.data.table.displayName,
},
// 선택된 모든 필드 정보를 선택 순서대로 전달
selectedFieldsData: (() => {
const orderedData: { [key: string]: { screenName: string; fields: string[] } } = {};
// 선택된 모든 컬럼 정보를 선택 순서대로 전달
selectedColumnsData: (() => {
const orderedData: { [key: string]: { displayName: string; columns: string[] } } = {};
// selectionOrder 순서대로 데이터 구성 (첫 번째 선택이 먼저)
orderedScreens.forEach((screenId) => {
const node = nodes.find((n) => n.data.screen.screenId === screenId);
if (node && selectedFields[screenId]) {
orderedData[screenId] = {
screenName: node.data.screen.screenName,
fields: selectedFields[screenId],
orderedTables.forEach((tableName) => {
const node = nodes.find((n) => n.data.table.tableName === tableName);
if (node && selectedColumns[tableName]) {
orderedData[tableName] = {
displayName: node.data.table.displayName,
columns: selectedColumns[tableName],
};
}
});
return orderedData;
})(),
// 명시적인 순서 정보 전달
orderedScreenIds: orderedScreens,
});
};
// 실제 화면 노드 추가
const addScreenNode = useCallback(
async (screen: ScreenDefinition) => {
// 실제 테이블 노드 추가
const addTableNode = useCallback(
async (table: TableDefinition) => {
try {
setLoadingScreens((prev) => new Set(prev).add(screen.screenId));
// 테이블 컬럼 정보 조회
const columns = await DataFlowAPI.getTableColumns(screen.tableName);
const newNode: Node = {
id: `screen-${screen.screenId}`,
type: "screenNode",
const newNode: Node<TableNodeData> = {
id: `table-${table.tableName}`,
type: "tableNode",
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
screen: {
screenId: screen.screenId.toString(),
screenName: screen.screenName,
screenCode: screen.screenCode,
tableName: screen.tableName,
fields: columns.map((col) => ({
table: {
tableName: table.tableName,
displayName: table.displayName || table.tableName,
description: table.description || "",
columns: table.columns.map((col) => ({
name: col.columnName || "unknown",
type: col.dataType || col.dbType || "UNKNOWN",
description:
col.columnLabel || col.displayName || col.description || col.columnName || "No description",
})),
},
onFieldClick: handleFieldClick,
onScrollAreaEnter: () => setIsOverNodeScrollArea(true),
onScrollAreaLeave: () => setIsOverNodeScrollArea(false),
selectedFields: selectedFields[screen.screenId] || [],
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[table.tableName] || [],
},
};
setNodes((nds) => nds.concat(newNode));
} catch (error) {
console.error("화면 노드 추가 실패:", error);
alert("화면 정보를 불러오는데 실패했습니다.");
} finally {
setLoadingScreens((prev) => {
const newSet = new Set(prev);
newSet.delete(screen.screenId);
return newSet;
});
console.error("테이블 노드 추가 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
}
},
[handleFieldClick, setNodes],
[handleColumnClick, selectedColumns, setNodes],
);
// 샘플 화면 노드 추가 (개발용)
// 샘플 테이블 노드 추가 (개발용)
const addSampleNode = useCallback(() => {
const newNode: Node = {
const tableName = `sample_table_${nodes.length + 1}`;
const newNode: Node<TableNodeData> = {
id: `sample-${Date.now()}`,
type: "screenNode",
type: "tableNode",
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
screen: {
screenId: `sample-${Date.now()}`,
screenName: `샘플 화면 ${nodes.length + 1}`,
screenCode: `SAMPLE${nodes.length + 1}`,
tableName: `sample_table_${nodes.length + 1}`,
fields: [
table: {
tableName,
displayName: `샘플 테이블 ${nodes.length + 1}`,
description: `샘플 테이블 설명 ${nodes.length + 1}`,
columns: [
{ name: "id", type: "INTEGER", description: "고유 식별자" },
{ name: "name", type: "VARCHAR(100)", description: "이름" },
{ name: "code", type: "VARCHAR(50)", description: "코드" },
{ name: "created_date", type: "TIMESTAMP", description: "생성일시" },
],
},
onFieldClick: handleFieldClick,
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[tableName] || [],
},
};
setNodes((nds) => nds.concat(newNode));
}, [nodes.length, handleFieldClick, setNodes]);
}, [nodes.length, handleColumnClick, selectedColumns, setNodes]);
// 노드 전체 삭제
const clearNodes = useCallback(() => {
setNodes([]);
setEdges([]);
setSelectedColumns({});
setSelectionOrder([]);
}, [setNodes, setEdges]);
// 현재 추가된 화면 ID 목록 가져오기
const getSelectedScreenIds = useCallback(() => {
return nodes
.filter((node) => node.id.startsWith("screen-"))
.map((node) => parseInt(node.id.replace("screen-", "")))
.filter((id) => !isNaN(id));
// 현재 추가된 테이블명 목록 가져오기
const getSelectedTableNames = useCallback(() => {
return nodes.filter((node) => node.id.startsWith("table-")).map((node) => node.data.table.tableName);
}, [nodes]);
// 연결 설정 확인
const handleConfirmConnection = useCallback(
(config: any) => {
(config: { relationshipType: string; connectionType: string; relationshipName: string }) => {
if (!pendingConnection) return;
const newEdge = {
id: `edge-${Date.now()}`,
source: pendingConnection.fromNode.id,
target: pendingConnection.toNode.id,
type: "customEdge",
type: "default",
data: {
relationshipType: config.relationshipType,
connectionType: config.connectionType,
@@ -319,19 +328,13 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
{/* 사이드바 */}
<div className="w-80 border-r border-gray-200 bg-white shadow-lg">
<div className="p-6">
<h2 className="mb-6 text-xl font-bold text-gray-800"> </h2>
<h2 className="mb-6 text-xl font-bold text-gray-800"> </h2>
{/* 회사 정보 */}
<div className="mb-6 rounded-lg bg-blue-50 p-4">
<div className="text-sm font-medium text-blue-600"> </div>
<div className="text-lg font-bold text-blue-800">{companyCode}</div>
</div>
{/* 화면 선택기 */}
<ScreenSelector
{/* 테이블 선택기 */}
<TableSelector
companyCode={companyCode}
onScreenAdd={addScreenNode}
selectedScreens={getSelectedScreenIds()}
onTableAdd={addTableNode}
selectedTables={getSelectedTableNames()}
/>
{/* 컨트롤 버튼들 */}
@@ -340,7 +343,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
onClick={addSampleNode}
className="w-full rounded-lg bg-gray-500 p-3 font-medium text-white transition-colors hover:bg-gray-600"
>
+ ()
+ ()
</button>
<button
@@ -363,7 +366,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
<div className="mb-2 text-sm font-semibold text-gray-700"></div>
<div className="space-y-1 text-sm text-gray-600">
<div className="flex justify-between">
<span> :</span>
<span> :</span>
<span className="font-medium">{nodes.length}</span>
</div>
<div className="flex justify-between">
@@ -373,41 +376,41 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
</div>
</div>
{/* 선택된 필드 정보 */}
{Object.keys(selectedFields).length > 0 && (
{/* 선택된 컬럼 정보 */}
{Object.keys(selectedColumns).length > 0 && (
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="mb-3 text-sm font-semibold text-blue-800"> </div>
<div className="mb-3 text-sm font-semibold text-blue-800"> </div>
<div className="space-y-3">
{[...new Set(selectionOrder)]
.filter((screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0)
.map((screenId, index, filteredOrder) => {
const fields = selectedFields[screenId];
const node = nodes.find((n) => n.data.screen.screenId === screenId);
const screenName = node?.data.screen.screenName || screenId;
.filter((tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0)
.map((tableName, index, filteredOrder) => {
const columns = selectedColumns[tableName];
const node = nodes.find((n) => n.data.table.tableName === tableName);
const displayName = node?.data.table.displayName || tableName;
return (
<div key={`selected-${screenId}-${index}`}>
<div key={`selected-${tableName}-${index}`}>
<div className="w-full min-w-0 rounded-lg border border-blue-300 bg-white p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<div className="flex-shrink-0 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">
{screenName}
{displayName}
</div>
<div className="flex-shrink-0 text-xs text-gray-500">ID: {screenId}</div>
<div className="flex-shrink-0 text-xs text-gray-500">{tableName}</div>
</div>
<div className="flex w-full min-w-0 flex-wrap gap-1">
{fields.map((field, fieldIndex) => (
{columns.map((column, columnIndex) => (
<div
key={`${screenId}-${field}-${fieldIndex}`}
key={`${tableName}-${column}-${columnIndex}`}
className="max-w-full truncate rounded-full border border-blue-200 bg-blue-100 px-2 py-1 text-xs text-blue-800"
title={field}
title={column}
>
{field}
{column}
</div>
))}
</div>
</div>
{/* 첫 번째 화면 다음에 화살표 표시 */}
{/* 첫 번째 테이블 다음에 화살표 표시 */}
{index === 0 && filteredOrder.length > 1 && (
<div className="flex justify-center py-2">
<div className="text-gray-400"></div>
@@ -427,11 +430,11 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
: "cursor-not-allowed bg-gray-300 text-gray-500"
}`}
>
</button>
<button
onClick={() => {
setSelectedFields({});
setSelectedColumns({});
setSelectionOrder([]);
}}
className="rounded bg-gray-200 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-300"
@@ -466,14 +469,14 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
<MiniMap
nodeColor={(node) => {
switch (node.type) {
case "screenNode":
case "tableNode":
return "#3B82F6";
default:
return "#6B7280";
}
}}
/>
<Background variant="dots" gap={20} size={1} color="#E5E7EB" />
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#E5E7EB" />
</ReactFlow>
{/* 안내 메시지 */}
@@ -481,8 +484,8 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<div className="mb-2 text-2xl">📊</div>
<div className="mb-1 text-lg font-medium"> </div>
<div className="text-sm"> </div>
<div className="mb-1 text-lg font-medium"> </div>
<div className="text-sm"> </div>
</div>
</div>
)}