feat: 화면 서브 테이블 정보 조회 기능 추가
- 화면 그룹에 대한 서브 테이블 관계를 조회하는 API 및 라우트 구현 - 화면 그룹 목록에서 서브 테이블 정보를 포함하여 데이터 흐름을 시각화 - 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 추가 - 화면 노드 및 관계 시각화 컴포넌트에 서브 테이블 정보 통합
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
useReactFlow,
|
||||
ReactFlowProvider,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
@@ -21,7 +23,10 @@ import {
|
||||
getDataFlows,
|
||||
getTableRelations,
|
||||
getMultipleScreenLayoutSummary,
|
||||
getScreenGroup,
|
||||
getScreenSubTables,
|
||||
ScreenLayoutSummary,
|
||||
ScreenSubTablesData,
|
||||
} from "@/lib/api/screenGroup";
|
||||
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||
|
||||
@@ -33,12 +38,15 @@ const nodeTypes = {
|
||||
|
||||
// 레이아웃 상수
|
||||
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
||||
const TABLE_Y = 400; // 테이블 노드 Y 위치 (하단)
|
||||
const NODE_WIDTH = 260; // 노드 너비 (조금 넓게)
|
||||
const TABLE_Y = 520; // 메인 테이블 노드 Y 위치 (중단)
|
||||
const SUB_TABLE_Y = 780; // 서브 테이블 노드 Y 위치 (하단)
|
||||
const NODE_WIDTH = 260; // 노드 너비
|
||||
const NODE_GAP = 40; // 노드 간격
|
||||
|
||||
interface ScreenRelationFlowProps {
|
||||
screen: ScreenDefinition | null;
|
||||
selectedGroup?: { id: number; name: string } | null;
|
||||
initialFocusedScreenId?: number | null;
|
||||
}
|
||||
|
||||
// 노드 타입 (Record<string, unknown> 확장)
|
||||
@@ -46,36 +54,57 @@ type ScreenNodeType = Node<ScreenNodeData & Record<string, unknown>>;
|
||||
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
||||
type AllNodeType = ScreenNodeType | TableNodeType;
|
||||
|
||||
export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
// 내부 컴포넌트 (useReactFlow 사용 가능)
|
||||
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: 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[]>>({});
|
||||
|
||||
|
||||
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
|
||||
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
|
||||
|
||||
// 외부에서 전달된 초기 포커스 ID 적용 (화면 이동 없이 강조만)
|
||||
useEffect(() => {
|
||||
if (initialFocusedScreenId !== undefined && initialFocusedScreenId !== null) {
|
||||
setFocusedScreenId(initialFocusedScreenId);
|
||||
}
|
||||
}, [initialFocusedScreenId]);
|
||||
|
||||
// 화면 ID와 테이블명 매핑 (포커스 시 연결선 강조용)
|
||||
const [screenTableMap, setScreenTableMap] = useState<Record<number, string>>({});
|
||||
|
||||
// 테이블 컬럼 정보 로드
|
||||
const loadTableColumns = useCallback(
|
||||
async (tableName: string): Promise<ColumnTypeInfo[]> => {
|
||||
if (!tableName) return [];
|
||||
if (tableColumns[tableName]) return tableColumns[tableName];
|
||||
|
||||
try {
|
||||
|
||||
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 [];
|
||||
} catch (error) {
|
||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[tableColumns]
|
||||
);
|
||||
|
||||
// 그룹 변경 시 focusedScreenId 초기화
|
||||
useEffect(() => {
|
||||
setFocusedScreenId(null);
|
||||
}, [selectedGroup?.id, screen?.screenId]);
|
||||
|
||||
// 데이터 로드 및 노드/엣지 생성
|
||||
useEffect(() => {
|
||||
if (!screen) {
|
||||
// 그룹도 없고 화면도 없으면 빈 상태
|
||||
if (!screen && !selectedGroup) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
return;
|
||||
@@ -83,22 +112,66 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
|
||||
const loadRelations = async () => {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
try {
|
||||
// 관계 데이터 로드
|
||||
let screenList: ScreenDefinition[] = [];
|
||||
|
||||
// ========== 그룹 선택 시: 그룹의 화면들 로드 ==========
|
||||
if (selectedGroup) {
|
||||
const groupRes = await getScreenGroup(selectedGroup.id);
|
||||
if (groupRes.success && groupRes.data) {
|
||||
const groupData = groupRes.data as any;
|
||||
const groupScreens = groupData.screens || [];
|
||||
|
||||
// display_order 순으로 정렬
|
||||
groupScreens.sort((a: any, b: any) => (a.display_order || 0) - (b.display_order || 0));
|
||||
|
||||
// screen_definitions 형식으로 변환 (table_name 포함)
|
||||
screenList = groupScreens.map((gs: any) => ({
|
||||
screenId: gs.screen_id,
|
||||
screenName: gs.screen_name || `화면 ${gs.screen_id}`,
|
||||
screenCode: gs.screen_code || "",
|
||||
tableName: gs.table_name || "", // 테이블명 포함
|
||||
companyCode: groupData.company_code,
|
||||
isActive: "Y",
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
screenRole: gs.screen_role, // screen_role 추가
|
||||
displayOrder: gs.display_order, // display_order 추가
|
||||
} as ScreenDefinition & { screenRole?: string; displayOrder?: number }));
|
||||
}
|
||||
} else if (screen) {
|
||||
// 기존 방식: 선택된 화면 중심
|
||||
screenList = [screen];
|
||||
}
|
||||
|
||||
if (screenList.length === 0) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 화면-테이블 매핑 저장 (포커스 시 연결선 강조용)
|
||||
const newScreenTableMap: Record<number, string> = {};
|
||||
screenList.forEach((scr: any) => {
|
||||
if (scr.tableName) {
|
||||
newScreenTableMap[scr.screenId] = scr.tableName;
|
||||
}
|
||||
});
|
||||
setScreenTableMap(newScreenTableMap);
|
||||
|
||||
// 관계 데이터 로드 (첫 번째 화면 기준)
|
||||
const [joinsRes, flowsRes, relationsRes] = await Promise.all([
|
||||
getFieldJoins(screen.screenId).catch(() => ({ success: false, data: [] })),
|
||||
getFieldJoins(screenList[0].screenId).catch(() => ({ success: false, data: [] })),
|
||||
getDataFlows().catch(() => ({ success: false, data: [] })),
|
||||
getTableRelations({ screen_id: screen.screenId }).catch(() => ({ success: false, data: [] })),
|
||||
getTableRelations({ screen_id: screenList[0].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) {
|
||||
@@ -121,23 +194,61 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
// 화면 레이아웃 요약 정보 로드
|
||||
const screenIds = screenList.map((s) => s.screenId);
|
||||
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
|
||||
let subTablesData: Record<number, ScreenSubTablesData> = {};
|
||||
try {
|
||||
const layoutRes = await getMultipleScreenLayoutSummary(screenIds);
|
||||
// 레이아웃 요약과 서브 테이블 정보 병렬 로드
|
||||
const [layoutRes, subTablesRes] = await Promise.all([
|
||||
getMultipleScreenLayoutSummary(screenIds),
|
||||
getScreenSubTables(screenIds),
|
||||
]);
|
||||
|
||||
if (layoutRes.success && layoutRes.data) {
|
||||
// API 응답이 Record 형태 (screenId -> summary)
|
||||
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
|
||||
}
|
||||
|
||||
if (subTablesRes.success && subTablesRes.data) {
|
||||
subTablesData = subTablesRes.data as Record<number, ScreenSubTablesData>;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("레이아웃 요약 로드 실패:", e);
|
||||
console.error("레이아웃 요약/서브 테이블 로드 실패:", e);
|
||||
}
|
||||
|
||||
// ========== 상단: 화면 노드들 ==========
|
||||
const screenNodes: ScreenNodeType[] = [];
|
||||
const screenStartX = 50;
|
||||
|
||||
// screen_role 레이블 매핑
|
||||
const getRoleLabel = (role?: string) => {
|
||||
if (!role || role === "member") return "화면";
|
||||
const roleMap: Record<string, string> = {
|
||||
main_list: "메인 그리드",
|
||||
register_form: "등록 폼",
|
||||
popup: "팝업",
|
||||
detail: "상세",
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
};
|
||||
|
||||
screenList.forEach((scr, idx) => {
|
||||
const isMain = scr.screenId === screen.screenId;
|
||||
screenList.forEach((scr: any, idx) => {
|
||||
const isMain = screen && scr.screenId === screen.screenId;
|
||||
const summary = layoutSummaries[scr.screenId];
|
||||
const roleLabel = getRoleLabel(scr.screenRole);
|
||||
|
||||
// 포커스 여부 결정 (그룹 모드 & 개별 화면 모드 모두 지원)
|
||||
const isInGroup = !!selectedGroup;
|
||||
let isFocused: boolean;
|
||||
let isFaded: boolean;
|
||||
|
||||
if (isInGroup) {
|
||||
// 그룹 모드: 클릭한 화면만 포커스
|
||||
isFocused = focusedScreenId === scr.screenId;
|
||||
isFaded = focusedScreenId !== null && !isFocused;
|
||||
} else {
|
||||
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
|
||||
isFocused = isMain;
|
||||
isFaded = !isMain && screenList.length > 1;
|
||||
}
|
||||
|
||||
screenNodes.push({
|
||||
id: `screen-${scr.screenId}`,
|
||||
@@ -145,43 +256,72 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
|
||||
data: {
|
||||
label: scr.screenName,
|
||||
subLabel: isMain ? "메인 화면" : "연결 화면",
|
||||
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
|
||||
type: "screen",
|
||||
isMain,
|
||||
isMain: selectedGroup ? idx === 0 : isMain,
|
||||
tableName: scr.tableName,
|
||||
layoutSummary: summary,
|
||||
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
|
||||
isInGroup,
|
||||
isFocused,
|
||||
isFaded,
|
||||
screenRole: scr.screenRole,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 하단: 테이블 노드들 ==========
|
||||
// ========== 중단: 메인 테이블 노드들 ==========
|
||||
const tableNodes: TableNodeType[] = [];
|
||||
const tableSet = new Set<string>();
|
||||
const mainTableSet = new Set<string>();
|
||||
const subTableSet = new Set<string>();
|
||||
|
||||
// 메인 화면의 테이블 추가
|
||||
if (screen.tableName) {
|
||||
tableSet.add(screen.tableName);
|
||||
}
|
||||
// 모든 화면의 메인 테이블 추가
|
||||
screenList.forEach((scr) => {
|
||||
if (scr.tableName) {
|
||||
mainTableSet.add(scr.tableName);
|
||||
}
|
||||
});
|
||||
|
||||
// 조인된 테이블들 추가
|
||||
// 조인된 테이블들 (screen_field_joins에서)
|
||||
joins.forEach((join: any) => {
|
||||
if (join.save_table) tableSet.add(join.save_table);
|
||||
if (join.join_table) tableSet.add(join.join_table);
|
||||
if (join.save_table) mainTableSet.add(join.save_table);
|
||||
if (join.join_table) mainTableSet.add(join.join_table);
|
||||
});
|
||||
|
||||
// 테이블 관계에서 추가
|
||||
relations.forEach((rel: any) => {
|
||||
if (rel.table_name) tableSet.add(rel.table_name);
|
||||
if (rel.table_name) mainTableSet.add(rel.table_name);
|
||||
});
|
||||
|
||||
// 서브 테이블 수집 (componentConfig에서 추출된 테이블들)
|
||||
// 서브 테이블은 메인 테이블과 다른 테이블들
|
||||
Object.values(subTablesData).forEach((screenSubData) => {
|
||||
screenSubData.subTables.forEach((subTable) => {
|
||||
// 메인 테이블에 없는 것만 서브 테이블로 추가
|
||||
if (!mainTableSet.has(subTable.tableName)) {
|
||||
subTableSet.add(subTable.tableName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 테이블 노드 배치 (하단, 가로 배치)
|
||||
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;
|
||||
|
||||
// 메인 테이블 노드 배치 (화면들의 중앙 아래에 배치)
|
||||
const mainTableList = Array.from(mainTableSet);
|
||||
|
||||
// 화면 노드들의 총 너비 계산
|
||||
const screenTotalWidth = screenList.length * NODE_WIDTH + (screenList.length - 1) * NODE_GAP;
|
||||
const screenCenterX = screenStartX + screenTotalWidth / 2;
|
||||
|
||||
// 메인 테이블 노드들의 총 너비 계산
|
||||
const mainTableTotalWidth = mainTableList.length * NODE_WIDTH + (mainTableList.length - 1) * NODE_GAP;
|
||||
const mainTableStartX = screenCenterX - mainTableTotalWidth / 2;
|
||||
|
||||
// 첫 번째 화면의 테이블 또는 선택된 화면의 테이블
|
||||
const primaryTableName = screen?.tableName || (screenList.length > 0 ? screenList[0].tableName : null);
|
||||
|
||||
for (let idx = 0; idx < mainTableList.length; idx++) {
|
||||
const tableName = mainTableList[idx];
|
||||
const isPrimaryTable = tableName === primaryTableName;
|
||||
|
||||
// 컬럼 정보 로드
|
||||
let columns: ColumnTypeInfo[] = [];
|
||||
try {
|
||||
@@ -201,33 +341,157 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
tableNodes.push({
|
||||
id: `table-${tableName}`,
|
||||
type: "tableNode",
|
||||
position: { x: tableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
|
||||
position: { x: mainTableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
|
||||
data: {
|
||||
label: tableName,
|
||||
subLabel: isMainTable ? "메인 테이블" : "조인 테이블",
|
||||
isMain: isMainTable,
|
||||
subLabel: isPrimaryTable ? "메인 테이블" : "조인 테이블",
|
||||
isMain: isPrimaryTable,
|
||||
columns: formattedColumns,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 하단: 서브 테이블 노드들 (참조/조회용) ==========
|
||||
const subTableList = Array.from(subTableSet);
|
||||
|
||||
if (subTableList.length > 0) {
|
||||
// 서브 테이블 노드들의 총 너비 계산
|
||||
const subTableTotalWidth = subTableList.length * NODE_WIDTH + (subTableList.length - 1) * NODE_GAP;
|
||||
const subTableStartX = screenCenterX - subTableTotalWidth / 2;
|
||||
|
||||
for (let idx = 0; idx < subTableList.length; idx++) {
|
||||
const tableName = subTableList[idx];
|
||||
|
||||
// 컬럼 정보 로드
|
||||
let columns: ColumnTypeInfo[] = [];
|
||||
try {
|
||||
columns = await loadTableColumns(tableName);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 컬럼 정보를 PK/FK 표시와 함께 변환
|
||||
const formattedColumns = columns.slice(0, 5).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"),
|
||||
}));
|
||||
|
||||
// 서브 테이블의 관계 타입 결정
|
||||
let relationType = "참조";
|
||||
Object.values(subTablesData).forEach((screenSubData) => {
|
||||
const matchedSub = screenSubData.subTables.find((st) => st.tableName === tableName);
|
||||
if (matchedSub) {
|
||||
if (matchedSub.relationType === "lookup") relationType = "조회";
|
||||
else if (matchedSub.relationType === "source") relationType = "데이터 소스";
|
||||
else if (matchedSub.relationType === "join") relationType = "조인";
|
||||
}
|
||||
});
|
||||
|
||||
tableNodes.push({
|
||||
id: `subtable-${tableName}`,
|
||||
type: "tableNode",
|
||||
position: { x: subTableStartX + idx * (NODE_WIDTH + NODE_GAP), y: SUB_TABLE_Y },
|
||||
data: {
|
||||
label: tableName,
|
||||
subLabel: `서브 테이블 (${relationType})`,
|
||||
isMain: false,
|
||||
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 },
|
||||
});
|
||||
|
||||
// 그룹 선택 시: 화면 간 연결선 (display_order 순)
|
||||
if (selectedGroup && screenList.length > 1) {
|
||||
for (let i = 0; i < screenList.length - 1; i++) {
|
||||
const currentScreen = screenList[i];
|
||||
const nextScreen = screenList[i + 1];
|
||||
|
||||
newEdges.push({
|
||||
id: `edge-screen-flow-${i}`,
|
||||
source: `screen-${currentScreen.screenId}`,
|
||||
target: `screen-${nextScreen.screenId}`,
|
||||
sourceHandle: "right",
|
||||
targetHandle: "left",
|
||||
type: "smoothstep",
|
||||
label: `${i + 1}`,
|
||||
labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 },
|
||||
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
|
||||
animated: true,
|
||||
style: { stroke: "#0ea5e9", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 각 화면 → 해당 메인 테이블 연결선 생성 (실선)
|
||||
screenList.forEach((scr, idx) => {
|
||||
if (scr.tableName && mainTableSet.has(scr.tableName)) {
|
||||
const isMain = screen ? scr.screenId === screen.screenId : idx === 0;
|
||||
newEdges.push({
|
||||
id: `edge-screen-table-${scr.screenId}`,
|
||||
source: `screen-${scr.screenId}`,
|
||||
target: `table-${scr.tableName}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
animated: isMain, // 메인 화면만 애니메이션
|
||||
style: {
|
||||
stroke: isMain ? "#3b82f6" : "#94a3b8",
|
||||
strokeWidth: isMain ? 2 : 1.5,
|
||||
strokeDasharray: isMain ? undefined : "5,5", // 보조 연결은 점선
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 테이블 → 서브 테이블 연결선 생성 (점선)
|
||||
Object.values(subTablesData).forEach((screenSubData) => {
|
||||
const mainTable = screenSubData.mainTable;
|
||||
if (!mainTable || !mainTableSet.has(mainTable)) return;
|
||||
|
||||
screenSubData.subTables.forEach((subTable) => {
|
||||
// 서브 테이블 노드가 실제로 생성되었는지 확인
|
||||
if (!subTableSet.has(subTable.tableName)) return;
|
||||
|
||||
// 중복 엣지 방지
|
||||
const edgeId = `edge-main-sub-${mainTable}-${subTable.tableName}`;
|
||||
const exists = newEdges.some((e) => e.id === edgeId);
|
||||
if (exists) return;
|
||||
|
||||
// 관계 타입에 따른 라벨
|
||||
let relationLabel = "참조";
|
||||
if (subTable.relationType === "lookup") relationLabel = "조회";
|
||||
else if (subTable.relationType === "source") relationLabel = "데이터 소스";
|
||||
else if (subTable.relationType === "join") relationLabel = "조인";
|
||||
|
||||
newEdges.push({
|
||||
id: edgeId,
|
||||
source: `table-${mainTable}`,
|
||||
target: `subtable-${subTable.tableName}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
label: relationLabel,
|
||||
labelStyle: { fontSize: 9, fill: "#f97316", fontWeight: 500 },
|
||||
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
||||
labelBgPadding: [3, 2] as [number, number],
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#f97316" },
|
||||
style: {
|
||||
stroke: "#f97316",
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: "6,4", // 점선
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
|
||||
joins.forEach((join: any, idx: number) => {
|
||||
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
|
||||
@@ -256,20 +520,20 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
|
||||
);
|
||||
if (!edgeExists) {
|
||||
newEdges.push({
|
||||
newEdges.push({
|
||||
id: `edge-rel-${idx}`,
|
||||
source: `screen-${screen.screenId}`,
|
||||
target: `table-${rel.table_name}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
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 },
|
||||
});
|
||||
}
|
||||
style: { stroke: "#10b981", strokeWidth: 1.5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -278,13 +542,13 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
.filter((flow: any) => flow.source_screen_id === screen.screenId)
|
||||
.forEach((flow: any, idx: number) => {
|
||||
if (flow.target_screen_id) {
|
||||
newEdges.push({
|
||||
newEdges.push({
|
||||
id: `edge-flow-${idx}`,
|
||||
source: `screen-${screen.screenId}`,
|
||||
target: `screen-${flow.target_screen_id}`,
|
||||
sourceHandle: "right",
|
||||
targetHandle: "left",
|
||||
type: "smoothstep",
|
||||
sourceHandle: "right",
|
||||
targetHandle: "left",
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
label: flow.flow_label || flow.flow_type || "이동",
|
||||
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
|
||||
@@ -292,9 +556,9 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
||||
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 최종 노드 배열 합치기
|
||||
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
|
||||
@@ -324,13 +588,145 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
};
|
||||
|
||||
loadRelations();
|
||||
}, [screen, setNodes, setEdges, loadTableColumns]);
|
||||
// focusedScreenId는 스타일링에만 영향을 미치므로 의존성에서 제외
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screen, selectedGroup, setNodes, setEdges, loadTableColumns]);
|
||||
|
||||
if (!screen) {
|
||||
// 노드 클릭 핸들러 (그룹 모드에서 화면 포커스) - 조건부 return 전에 선언해야 함
|
||||
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
// 그룹 모드가 아니면 무시
|
||||
if (!selectedGroup) return;
|
||||
|
||||
// 화면 노드만 처리
|
||||
if (node.id.startsWith("screen-")) {
|
||||
const screenId = parseInt(node.id.replace("screen-", ""));
|
||||
// 이미 포커스된 화면을 다시 클릭하면 포커스 해제
|
||||
setFocusedScreenId((prev) => (prev === screenId ? null : screenId));
|
||||
}
|
||||
}, [selectedGroup]);
|
||||
|
||||
// 포커스에 따른 노드 스타일링 (그룹 모드에서 화면 클릭 시)
|
||||
const styledNodes = React.useMemo(() => {
|
||||
// 그룹 모드에서 포커스된 화면이 있을 때만 추가 스타일링
|
||||
if (!selectedGroup || focusedScreenId === null) return nodes;
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (node.id.startsWith("screen-")) {
|
||||
const screenId = parseInt(node.id.replace("screen-", ""));
|
||||
const isFocused = screenId === focusedScreenId;
|
||||
const isFaded = !isFocused;
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
isFocused,
|
||||
isFaded,
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}, [nodes, selectedGroup, focusedScreenId]);
|
||||
|
||||
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
|
||||
const styledEdges = React.useMemo(() => {
|
||||
// 개별 화면 모드: 메인 화면의 연결선만 강조
|
||||
if (!selectedGroup && screen) {
|
||||
const mainScreenId = screen.screenId;
|
||||
|
||||
return edges.map((edge) => {
|
||||
// 화면 간 연결선
|
||||
if (edge.source.startsWith("screen-") && edge.target.startsWith("screen-")) {
|
||||
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
||||
const targetId = parseInt(edge.target.replace("screen-", ""));
|
||||
const isConnected = sourceId === mainScreenId || targetId === mainScreenId;
|
||||
|
||||
return {
|
||||
...edge,
|
||||
animated: isConnected,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
||||
strokeWidth: isConnected ? 2 : 1,
|
||||
opacity: isConnected ? 1 : 0.3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 화면-테이블 연결선
|
||||
if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) {
|
||||
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
||||
const isMyConnection = sourceId === mainScreenId;
|
||||
|
||||
return {
|
||||
...edge,
|
||||
animated: isMyConnection,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
||||
strokeWidth: isMyConnection ? 2 : 1,
|
||||
strokeDasharray: isMyConnection ? undefined : "5,5",
|
||||
opacity: isMyConnection ? 1 : 0.3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return edge;
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 모드: 포커스된 화면이 없으면 원본 반환
|
||||
if (!selectedGroup || focusedScreenId === null) return edges;
|
||||
|
||||
return edges.map((edge) => {
|
||||
// 화면 간 연결선 (1, 2, 3 라벨)
|
||||
if (edge.source.startsWith("screen-") && edge.target.startsWith("screen-")) {
|
||||
// 포커스된 화면과 연결된 화면 간 선만 활성화
|
||||
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
||||
const targetId = parseInt(edge.target.replace("screen-", ""));
|
||||
const isConnected = sourceId === focusedScreenId || targetId === focusedScreenId;
|
||||
|
||||
return {
|
||||
...edge,
|
||||
animated: isConnected,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
||||
strokeWidth: isConnected ? 2 : 1,
|
||||
opacity: isConnected ? 1 : 0.3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 화면-테이블 연결선
|
||||
if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) {
|
||||
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
||||
const isMyConnection = sourceId === focusedScreenId;
|
||||
|
||||
return {
|
||||
...edge,
|
||||
animated: isMyConnection,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
||||
strokeWidth: isMyConnection ? 2 : 1,
|
||||
strokeDasharray: isMyConnection ? undefined : "5,5",
|
||||
opacity: isMyConnection ? 1 : 0.3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return edge;
|
||||
});
|
||||
}, [edges, selectedGroup, focusedScreenId, screen]);
|
||||
|
||||
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
|
||||
if (!screen && !selectedGroup) {
|
||||
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>
|
||||
<p className="text-sm">데이터 관계가 시각화됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,10 +744,11 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={styledNodes}
|
||||
edges={styledEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
@@ -365,3 +762,12 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 외부 래퍼 컴포넌트 (ReactFlowProvider 포함)
|
||||
export function ScreenRelationFlow(props: ScreenRelationFlowProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ScreenRelationFlowInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user