368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
ReactFlow,
|
|
Controls,
|
|
Background,
|
|
BackgroundVariant,
|
|
Node,
|
|
Edge,
|
|
useNodesState,
|
|
useEdgesState,
|
|
MarkerType,
|
|
} from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
|
|
import { ScreenDefinition } from "@/types/screen";
|
|
import { ScreenNode, TableNode, ScreenNodeData, TableNodeData } from "./ScreenNode";
|
|
import {
|
|
getFieldJoins,
|
|
getDataFlows,
|
|
getTableRelations,
|
|
getMultipleScreenLayoutSummary,
|
|
ScreenLayoutSummary,
|
|
} from "@/lib/api/screenGroup";
|
|
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
|
|
|
// 노드 타입 등록
|
|
const nodeTypes = {
|
|
screenNode: ScreenNode,
|
|
tableNode: TableNode,
|
|
};
|
|
|
|
// 레이아웃 상수
|
|
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
|
const TABLE_Y = 400; // 테이블 노드 Y 위치 (하단)
|
|
const NODE_WIDTH = 260; // 노드 너비 (조금 넓게)
|
|
const NODE_GAP = 40; // 노드 간격
|
|
|
|
interface ScreenRelationFlowProps {
|
|
screen: ScreenDefinition | null;
|
|
}
|
|
|
|
// 노드 타입 (Record<string, unknown> 확장)
|
|
type ScreenNodeType = Node<ScreenNodeData & Record<string, unknown>>;
|
|
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
|
type AllNodeType = ScreenNodeType | TableNodeType;
|
|
|
|
export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
|
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
|
|
|
|
// 테이블 컬럼 정보 로드
|
|
const loadTableColumns = useCallback(
|
|
async (tableName: string): Promise<ColumnTypeInfo[]> => {
|
|
if (!tableName) return [];
|
|
if (tableColumns[tableName]) return tableColumns[tableName];
|
|
|
|
try {
|
|
const response = await getTableColumns(tableName);
|
|
if (response.success && response.data && response.data.columns) {
|
|
const columns = response.data.columns;
|
|
setTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
|
return columns;
|
|
}
|
|
} catch (error) {
|
|
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
|
}
|
|
return [];
|
|
},
|
|
[tableColumns]
|
|
);
|
|
|
|
// 데이터 로드 및 노드/엣지 생성
|
|
useEffect(() => {
|
|
if (!screen) {
|
|
setNodes([]);
|
|
setEdges([]);
|
|
return;
|
|
}
|
|
|
|
const loadRelations = async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
// 관계 데이터 로드
|
|
const [joinsRes, flowsRes, relationsRes] = await Promise.all([
|
|
getFieldJoins(screen.screenId).catch(() => ({ success: false, data: [] })),
|
|
getDataFlows().catch(() => ({ success: false, data: [] })),
|
|
getTableRelations({ screen_id: screen.screenId }).catch(() => ({ success: false, data: [] })),
|
|
]);
|
|
|
|
const joins = joinsRes.success ? joinsRes.data || [] : [];
|
|
const flows = flowsRes.success ? flowsRes.data || [] : [];
|
|
const relations = relationsRes.success ? relationsRes.data || [] : [];
|
|
|
|
// ========== 화면 목록 수집 ==========
|
|
const screenList: ScreenDefinition[] = [screen];
|
|
|
|
// 데이터 흐름에서 연결된 화면들 추가
|
|
flows.forEach((flow: any) => {
|
|
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
|
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
|
|
if (!exists) {
|
|
screenList.push({
|
|
screenId: flow.target_screen_id,
|
|
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
|
|
screenCode: "",
|
|
tableName: "",
|
|
companyCode: screen.companyCode,
|
|
isActive: "Y",
|
|
createdDate: new Date(),
|
|
updatedDate: new Date(),
|
|
} as ScreenDefinition);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 화면 레이아웃 요약 정보 로드
|
|
const screenIds = screenList.map((s) => s.screenId);
|
|
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
|
|
try {
|
|
const layoutRes = await getMultipleScreenLayoutSummary(screenIds);
|
|
if (layoutRes.success && layoutRes.data) {
|
|
// API 응답이 Record 형태 (screenId -> summary)
|
|
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
|
|
}
|
|
} catch (e) {
|
|
console.error("레이아웃 요약 로드 실패:", e);
|
|
}
|
|
|
|
// ========== 상단: 화면 노드들 ==========
|
|
const screenNodes: ScreenNodeType[] = [];
|
|
const screenStartX = 50;
|
|
|
|
screenList.forEach((scr, idx) => {
|
|
const isMain = scr.screenId === screen.screenId;
|
|
const summary = layoutSummaries[scr.screenId];
|
|
|
|
screenNodes.push({
|
|
id: `screen-${scr.screenId}`,
|
|
type: "screenNode",
|
|
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
|
|
data: {
|
|
label: scr.screenName,
|
|
subLabel: isMain ? "메인 화면" : "연결 화면",
|
|
type: "screen",
|
|
isMain,
|
|
tableName: scr.tableName,
|
|
layoutSummary: summary,
|
|
},
|
|
});
|
|
});
|
|
|
|
// ========== 하단: 테이블 노드들 ==========
|
|
const tableNodes: TableNodeType[] = [];
|
|
const tableSet = new Set<string>();
|
|
|
|
// 메인 화면의 테이블 추가
|
|
if (screen.tableName) {
|
|
tableSet.add(screen.tableName);
|
|
}
|
|
|
|
// 조인된 테이블들 추가
|
|
joins.forEach((join: any) => {
|
|
if (join.save_table) tableSet.add(join.save_table);
|
|
if (join.join_table) tableSet.add(join.join_table);
|
|
});
|
|
|
|
// 테이블 관계에서 추가
|
|
relations.forEach((rel: any) => {
|
|
if (rel.table_name) tableSet.add(rel.table_name);
|
|
});
|
|
|
|
// 테이블 노드 배치 (하단, 가로 배치)
|
|
const tableList = Array.from(tableSet);
|
|
const tableStartX = 50;
|
|
|
|
for (let idx = 0; idx < tableList.length; idx++) {
|
|
const tableName = tableList[idx];
|
|
const isMainTable = tableName === screen.tableName;
|
|
|
|
// 컬럼 정보 로드
|
|
let columns: ColumnTypeInfo[] = [];
|
|
try {
|
|
columns = await loadTableColumns(tableName);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
// 컬럼 정보를 PK/FK 표시와 함께 변환
|
|
const formattedColumns = columns.slice(0, 8).map((col) => ({
|
|
name: col.displayName || col.columnName || "",
|
|
type: col.dataType || "",
|
|
isPrimaryKey: col.isPrimaryKey || col.columnName === "id",
|
|
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
|
|
}));
|
|
|
|
tableNodes.push({
|
|
id: `table-${tableName}`,
|
|
type: "tableNode",
|
|
position: { x: tableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
|
|
data: {
|
|
label: tableName,
|
|
subLabel: isMainTable ? "메인 테이블" : "조인 테이블",
|
|
isMain: isMainTable,
|
|
columns: formattedColumns,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ========== 엣지: 연결선 생성 ==========
|
|
const newEdges: Edge[] = [];
|
|
|
|
// 메인 화면 → 메인 테이블 연결 (양방향 CRUD)
|
|
if (screen.tableName) {
|
|
newEdges.push({
|
|
id: `edge-main`,
|
|
source: `screen-${screen.screenId}`,
|
|
target: `table-${screen.tableName}`,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
type: "smoothstep",
|
|
animated: true,
|
|
style: { stroke: "#3b82f6", strokeWidth: 2 },
|
|
});
|
|
}
|
|
|
|
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
|
|
joins.forEach((join: any, idx: number) => {
|
|
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
|
|
newEdges.push({
|
|
id: `edge-join-${idx}`,
|
|
source: `table-${join.save_table}`,
|
|
target: `table-${join.join_table}`,
|
|
sourceHandle: "right",
|
|
targetHandle: "left",
|
|
type: "smoothstep",
|
|
label: "1:N 관계",
|
|
labelStyle: { fontSize: 10, fill: "#6366f1", fontWeight: 500 },
|
|
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
|
labelBgPadding: [4, 2] as [number, number],
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: "#6366f1" },
|
|
style: { stroke: "#6366f1", strokeWidth: 1.5, strokeDasharray: "5,5" },
|
|
});
|
|
}
|
|
});
|
|
|
|
// 테이블 관계 엣지 (추가 관계)
|
|
relations.forEach((rel: any, idx: number) => {
|
|
if (rel.table_name && rel.table_name !== screen.tableName) {
|
|
// 화면 → 연결 테이블
|
|
const edgeExists = newEdges.some(
|
|
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
|
|
);
|
|
if (!edgeExists) {
|
|
newEdges.push({
|
|
id: `edge-rel-${idx}`,
|
|
source: `screen-${screen.screenId}`,
|
|
target: `table-${rel.table_name}`,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
type: "smoothstep",
|
|
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
|
|
labelStyle: { fontSize: 9, fill: "#10b981" },
|
|
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
|
labelBgPadding: [3, 2] as [number, number],
|
|
style: { stroke: "#10b981", strokeWidth: 1.5 },
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// 데이터 흐름 엣지 (화면 간)
|
|
flows
|
|
.filter((flow: any) => flow.source_screen_id === screen.screenId)
|
|
.forEach((flow: any, idx: number) => {
|
|
if (flow.target_screen_id) {
|
|
newEdges.push({
|
|
id: `edge-flow-${idx}`,
|
|
source: `screen-${screen.screenId}`,
|
|
target: `screen-${flow.target_screen_id}`,
|
|
sourceHandle: "right",
|
|
targetHandle: "left",
|
|
type: "smoothstep",
|
|
animated: true,
|
|
label: flow.flow_label || flow.flow_type || "이동",
|
|
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
|
|
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
|
labelBgPadding: [4, 2] as [number, number],
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
|
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
|
});
|
|
}
|
|
});
|
|
|
|
// 최종 노드 배열 합치기
|
|
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
|
|
|
|
// 테이블이 없으면 안내 노드 추가
|
|
if (tableNodes.length === 0) {
|
|
allNodes.push({
|
|
id: "hint-table",
|
|
type: "tableNode",
|
|
position: { x: 50, y: TABLE_Y },
|
|
data: {
|
|
label: "연결된 테이블 없음",
|
|
subLabel: "화면에 테이블을 설정하세요",
|
|
isMain: false,
|
|
columns: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
setNodes(allNodes);
|
|
setEdges(newEdges);
|
|
} catch (error) {
|
|
console.error("관계 데이터 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadRelations();
|
|
}, [screen, setNodes, setEdges, loadTableColumns]);
|
|
|
|
if (!screen) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<div className="text-center">
|
|
<p className="text-sm">화면을 선택하면</p>
|
|
<p className="text-sm">데이터 관계가 시각화됩니다</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full w-full">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
minZoom={0.3}
|
|
maxZoom={1.5}
|
|
proOptions={{ hideAttribution: true }}
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
|
<Controls position="bottom-right" />
|
|
</ReactFlow>
|
|
</div>
|
|
);
|
|
}
|