feat: 저장 테이블 제외 조건 추가 및 포커싱 개선

- 저장 테이블 쿼리에 table-list와 체크박스가 활성화된 화면, openModalWithData 버튼이 있는 화면을 제외하는 조건 추가
- 화면 그룹 클릭 시 새 그룹 진입 시 포커싱 없이 시작하도록 로직 개선
- 관련 문서에 제외 조건 및 SQL 예시 추가
This commit is contained in:
DDD1542
2026-01-09 17:03:00 +09:00
parent af4072cef1
commit a6569909a2
10 changed files with 4880 additions and 70 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -569,7 +569,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
// 저장 대상 여부
const hasSaveTarget = saveInfos && saveInfos.length > 0;
return (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
@@ -670,7 +670,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</span>
)}
</div>
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
<div

View File

@@ -32,6 +32,8 @@ import {
VisualRelationType,
} from "@/lib/api/screenGroup";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
import { ScreenSettingModal } from "./ScreenSettingModal";
import { TableSettingModal } from "./TableSettingModal";
// 관계 유형별 색상 정의
const RELATION_COLORS: Record<VisualRelationType, { stroke: string; strokeLight: string; label: string }> = {
@@ -86,6 +88,64 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
// 노드 설정 모달 상태
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [settingModalNode, setSettingModalNode] = useState<{
nodeType: "screen" | "table";
nodeId: string;
screenId: number;
screenName: string;
tableName?: string;
tableLabel?: string;
// 기존 설정 정보 (화면 디자이너에서 추출)
existingConfig?: {
joinColumnRefs?: Array<{
column: string;
refTable: string;
refTableLabel?: string;
refColumn: string;
}>;
filterColumns?: string[];
fieldMappings?: Array<{
targetField: string;
sourceField: string;
sourceTable?: string;
sourceDisplayName?: string;
}>;
referencedBy?: Array<{
fromTable: string;
fromTableLabel?: string;
fromColumn: string;
toColumn: string;
toColumnLabel?: string;
relationType: string;
}>;
columns?: Array<{
name: string;
originalName?: string;
type: string;
isPrimaryKey?: boolean;
isForeignKey?: boolean;
}>;
// 화면 노드용 테이블 정보
mainTable?: string;
filterTables?: Array<{
tableName: string;
tableLabel: string;
filterColumns: string[];
joinColumnRefs: Array<{
column: string;
refTable: string;
refTableLabel?: string;
refColumn: string;
}>;
}>;
};
} | null>(null);
// 강제 새로고침용 키 (설정 저장 후 시각화 재로딩)
const [refreshKey, setRefreshKey] = useState(0);
// 그룹 또는 화면이 변경될 때 포커스 초기화
useEffect(() => {
setFocusedScreenId(null);
@@ -814,12 +874,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
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 = "조인";
// 관계 유형 결정 (스타일링용)
const visualRelationType = inferVisualRelationType(subTable);
const relationColor = RELATION_COLORS[visualRelationType];
// 메인-서브 조인선 (메인-메인과 동일한 스타일, 라벨 없음)
newEdges.push({
id: edgeId,
source: `table-${mainTable}`,
@@ -827,50 +886,47 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
label: relationLabel,
labelStyle: {
fontSize: 9,
fill: "#94a3b8",
fontWeight: 500
},
labelBgStyle: {
fill: "white",
stroke: "#e2e8f0",
strokeWidth: 1
},
labelBgPadding: [3, 2] as [number, number],
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#94a3b8"
color: relationColor.strokeLight
},
animated: false, // 기본: 애니메이션 비활성화 (포커스 시에만 활성화)
animated: false,
style: {
stroke: "#94a3b8",
strokeWidth: 1,
strokeDasharray: "6,4", // 점선
opacity: 0.5, // 기본 투명도
stroke: relationColor.strokeLight,
strokeWidth: 1.5,
strokeDasharray: "8,4",
opacity: 0.5,
},
data: {
sourceScreenId,
visualRelationType,
},
data: { sourceScreenId },
});
});
});
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
// 조인 관계 엣지 (screen_field_joins 기반 - 라벨 없이 통일된 스타일)
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}`,
id: `edge-join-db-${idx}`,
source: `table-${join.save_table}`,
target: `table-${join.join_table}`,
sourceHandle: "right",
targetHandle: "left",
sourceHandle: "bottom",
targetHandle: "bottom_target",
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" },
markerEnd: {
type: MarkerType.ArrowClosed,
color: RELATION_COLORS.join.strokeLight
},
animated: false,
style: {
stroke: RELATION_COLORS.join.strokeLight,
strokeWidth: 1.5,
strokeDasharray: "8,4",
opacity: 0.5,
},
data: { visualRelationType: 'join' },
});
}
});
@@ -955,8 +1011,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
loadRelations();
// focusedScreenId는 스타일링에만 영향을 미치므로 의존성에서 제외
// refreshKey: 설정 저장 후 강제 새로고침용
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screen, selectedGroup, setNodes, setEdges, loadTableColumns]);
}, [screen, selectedGroup, setNodes, setEdges, loadTableColumns, refreshKey]);
// 데이터 로드 완료 시 fitView 호출 (초기 로드 시에만)
useEffect(() => {
@@ -984,6 +1041,198 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
setFocusedScreenId((prev) => (prev === screenId ? null : screenId));
}
}, [selectedGroup]);
// 테이블 정보에서 조인/필터 정보 추출 (더블클릭 핸들러용)
const getTableExistingConfig = useCallback((tableName: string) => {
// subTablesDataMap에서 서브 테이블 정보 찾기
for (const screenId in subTablesDataMap) {
const screenSubTables = subTablesDataMap[parseInt(screenId)];
if (screenSubTables?.subTables) {
const subTable = screenSubTables.subTables.find(st => st.tableName === tableName);
if (subTable) {
return {
joinColumnRefs: subTable.joinColumnRefs,
filterColumns: subTable.filterColumns,
fieldMappings: subTable.fieldMappings?.map(m => ({
targetField: m.targetField,
sourceField: m.sourceField,
sourceTable: m.sourceTable,
sourceDisplayName: m.sourceDisplayName,
})),
columns: [], // 컬럼 정보는 노드에서 가져옴
};
}
}
}
return undefined;
}, [subTablesDataMap]);
// 노드 우클릭 핸들러 (설정 모달 열기)
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
// 기본 컨텍스트 메뉴 방지
event.preventDefault();
// 화면 노드 우클릭
if (node.id.startsWith("screen-")) {
const screenId = parseInt(node.id.replace("screen-", ""));
const nodeData = node.data as ScreenNodeData;
const mainTable = screenTableMap[screenId];
// 해당 화면의 서브 테이블 (필터 테이블) 정보
// 1. screenSubTableMap에서 가져오기
const screenSubTables = screenSubTableMap[screenId] || [];
// 2. edges에서 필터 테이블 찾기 (edge-screen-filter-{screenId}-{tableName})
const filterTableNamesFromEdges = edges
.filter(e => e.id.startsWith(`edge-screen-filter-${screenId}-`))
.map(e => {
const match = e.id.match(/edge-screen-filter-\d+-(.+)/);
return match ? match[1] : null;
})
.filter((name): name is string => name !== null);
// 모든 필터 테이블 합치기 (중복 제거)
const allFilterTableNames = [...new Set([...screenSubTables, ...filterTableNamesFromEdges])];
const filterTables = allFilterTableNames.map(tableName => {
// subTablesDataMap에서 해당 테이블 정보 찾기
const subTableData = subTablesDataMap[screenId]?.subTables?.find(
st => st.tableName === tableName
);
// 또는 nodes에서 테이블 노드 정보 찾기
const tableNode = nodes.find(n =>
n.id === `table-${tableName}` || n.id === `subtable-${tableName}`
);
const tableNodeData = tableNode?.data as TableNodeData | undefined;
return {
tableName,
tableLabel: subTableData?.tableLabel || tableNodeData?.label || tableName,
filterColumns: subTableData?.filterColumns || tableNodeData?.filterColumns || [],
joinColumnRefs: subTableData?.joinColumnRefs || tableNodeData?.joinColumnRefs || [],
};
});
setSettingModalNode({
nodeType: "screen",
nodeId: node.id,
screenId: screenId,
screenName: nodeData.label || `화면 ${screenId}`,
tableName: mainTable,
tableLabel: nodeData.subLabel,
// 화면의 테이블 정보 전달
existingConfig: {
mainTable: mainTable,
filterTables: filterTables,
},
});
setIsSettingModalOpen(true);
return;
}
// 메인 테이블 노드 더블클릭
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
const tableName = node.id.replace("table-", "");
const nodeData = node.data as TableNodeData;
// 이 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenTableMap).find(
([_, tbl]) => tbl === tableName
)?.[0];
// 백엔드에서 받은 데이터에서 기존 설정 정보 추출
const existingConfigFromData = getTableExistingConfig(tableName);
setSettingModalNode({
nodeType: "table",
nodeId: node.id,
screenId: screenId ? parseInt(screenId) : 0,
screenName: nodeData.subLabel || tableName,
tableName: tableName,
tableLabel: nodeData.label,
// 기존 설정 정보 전달
existingConfig: existingConfigFromData || {
joinColumnRefs: nodeData.joinColumnRefs,
filterColumns: nodeData.filterColumns,
fieldMappings: nodeData.fieldMappings?.map(m => ({
targetField: m.targetField,
sourceField: m.sourceField,
sourceTable: m.sourceTable,
sourceDisplayName: m.sourceDisplayName,
})),
referencedBy: nodeData.referencedBy?.map(r => ({
fromTable: r.fromTable,
fromTableLabel: r.fromTableLabel,
fromColumn: r.fromColumn,
toColumn: r.toColumn,
toColumnLabel: r.toColumnLabel,
relationType: r.relationType,
})),
columns: nodeData.columns,
},
});
setIsSettingModalOpen(true);
return;
}
// 서브 테이블 노드 더블클릭
if (node.id.startsWith("subtable-")) {
const tableName = node.id.replace("subtable-", "");
const nodeData = node.data as TableNodeData;
// 이 서브 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenSubTableMap).find(
([_, tables]) => tables.includes(tableName)
)?.[0];
// 백엔드에서 받은 데이터에서 기존 설정 정보 추출
const existingConfigFromData = getTableExistingConfig(tableName);
setSettingModalNode({
nodeType: "table",
nodeId: node.id,
screenId: screenId ? parseInt(screenId) : 0,
screenName: nodeData.subLabel || tableName,
tableName: tableName,
tableLabel: nodeData.label,
// 기존 설정 정보 전달
existingConfig: existingConfigFromData || {
joinColumnRefs: nodeData.joinColumnRefs,
filterColumns: nodeData.filterColumns,
fieldMappings: nodeData.fieldMappings?.map(m => ({
targetField: m.targetField,
sourceField: m.sourceField,
sourceTable: m.sourceTable,
sourceDisplayName: m.sourceDisplayName,
})),
referencedBy: nodeData.referencedBy?.map(r => ({
fromTable: r.fromTable,
fromTableLabel: r.fromTableLabel,
fromColumn: r.fromColumn,
toColumn: r.toColumn,
toColumnLabel: r.toColumnLabel,
relationType: r.relationType,
})),
columns: nodeData.columns,
},
});
setIsSettingModalOpen(true);
return;
}
}, [screenTableMap, screenSubTableMap, subTablesDataMap, edges, nodes, getTableExistingConfig]);
// 설정 모달 닫기 및 새로고침
const handleSettingModalClose = useCallback(() => {
setIsSettingModalOpen(false);
setSettingModalNode(null);
}, []);
// 시각화 새로고침 (설정 저장 후 호출)
const handleRefreshVisualization = useCallback(() => {
// 강제 새로고침: refreshKey 증가로 useEffect 재실행
setRefreshKey(prev => prev + 1);
}, []);
// 포커스에 따른 노드 스타일링 (그룹 모드에서 화면 클릭 시)
const styledNodes = React.useMemo(() => {
@@ -1686,14 +1935,47 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 화면-테이블 연결선
if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) {
const sourceId = parseInt(edge.source.replace("screen-", ""));
const isMyConnection = sourceId === focusedScreenId;
// 필터 연결선 (edge-screen-filter-)은 포커싱 시에만 표시
const isFilterEdge = edge.id.startsWith("edge-screen-filter-");
if (isFilterEdge) {
// 포커스가 없거나 다른 화면 포커스 시 숨김
if (focusedScreenId === null || !isMyConnection) {
return {
...edge,
animated: false,
style: {
...edge.style,
stroke: "transparent",
strokeWidth: 0,
opacity: 0,
},
};
}
// 포커싱된 화면의 필터 연결선은 표시
return {
...edge,
animated: true,
style: {
...edge.style,
stroke: "#3b82f6",
strokeWidth: 2,
strokeDasharray: "5,5",
opacity: 1,
},
};
}
// 메인 테이블 연결선 (edge-screen-table-)은 기존 로직
// 포커스가 없으면 모든 화면-테이블 연결선 정상 표시
if (focusedScreenId === null) {
return edge; // 원본 그대로
}
const sourceId = parseInt(edge.source.replace("screen-", ""));
const isMyConnection = sourceId === focusedScreenId;
return {
...edge,
animated: isMyConnection,
@@ -1707,33 +1989,36 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
};
}
// 메인 테이블 → 서브 테이블 연결선
// 메인 테이블 → 서브 테이블 연결선 (메인-메인과 동일한 스타일)
// 규격: bottom → top 고정 (아래로 문어발처럼 뻗어나감)
if (edge.source.startsWith("table-") && edge.target.startsWith("subtable-")) {
// 관계 유형별 색상 결정
const visualRelationType = (edge.data as any)?.visualRelationType as VisualRelationType || 'join';
const relationColor = RELATION_COLORS[visualRelationType];
// 포커스가 없으면 모든 서브 테이블 연결선 흐리게 (기본 상태)
if (focusedScreenId === null) {
return {
...edge,
sourceHandle: "bottom", // 고정: 메인 테이블 하단에서 나감
targetHandle: "top", // 고정: 서브 테이블 상단으로 들어감
sourceHandle: "bottom",
targetHandle: "top",
animated: false,
style: {
...edge.style,
stroke: "#d1d5db",
strokeWidth: 1,
strokeDasharray: "6,4",
opacity: 0.3,
stroke: relationColor.strokeLight,
strokeWidth: 1.5,
strokeDasharray: "8,4",
opacity: 0.4,
},
labelStyle: {
...edge.labelStyle,
opacity: 0.3,
markerEnd: {
type: MarkerType.ArrowClosed,
color: relationColor.strokeLight,
},
};
}
// 엣지 ID에서 화면 ID 추출: edge-main-sub-{screenId}-{mainTable}-{subTable}
const idParts = edge.id.split("-");
// edge-main-sub-1413-sales_order_mng-customer_mng 형식
const edgeScreenId = idParts.length >= 4 ? parseInt(idParts[3]) : null;
// 포커스된 화면의 서브 테이블 연결인지 확인
@@ -1748,20 +2033,20 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
return {
...edge,
sourceHandle: "bottom", // 고정
targetHandle: "top", // 고정
animated: isActive, // 활성화된 것만 애니메이션
sourceHandle: "bottom",
targetHandle: "top",
animated: isActive,
style: {
...edge.style,
stroke: isActive ? RELATION_COLORS.join.stroke : "#d1d5db", // 상수 사용
strokeWidth: isActive ? 2.5 : 1,
strokeDasharray: "6,4", // 항상 점선
opacity: isActive ? 1 : 0.2,
},
labelStyle: {
...edge.labelStyle,
stroke: isActive ? relationColor.stroke : relationColor.strokeLight,
strokeWidth: isActive ? 2.5 : 1.5,
strokeDasharray: "8,4",
opacity: isActive ? 1 : 0.3,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isActive ? relationColor.stroke : relationColor.strokeLight,
},
};
}
@@ -1820,7 +2105,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 메인 테이블 → 메인 테이블 연결선 (서브테이블 구간 통과)
// 규격: bottom → bottom_target 고정 (아래쪽 서브테이블 선 구간을 통해 연결)
if (edge.source.startsWith("table-") && edge.target.startsWith("table-") && edge.id.startsWith("edge-main-main-")) {
// edge-main-main-*, edge-join-db-* 모두 동일한 스타일 적용
const isMainToMainJoin = edge.source.startsWith("table-") &&
edge.target.startsWith("table-") &&
(edge.id.startsWith("edge-main-main-") || edge.id.startsWith("edge-join-db-"));
if (isMainToMainJoin) {
// 관계 유형별 색상 결정
const visualRelationType = (edge.data as any)?.visualRelationType as VisualRelationType || 'join';
const relationColor = RELATION_COLORS[visualRelationType];
@@ -1882,6 +2171,18 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
return [...styledOriginalEdges, ...joinEdges];
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
// 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함
const groupScreensList = React.useMemo(() => {
if (!selectedGroup) return [];
// nodes에서 screen- 으로 시작하는 노드들 추출
return nodes
.filter(n => n.id.startsWith("screen-"))
.map(n => ({
screen_id: parseInt(n.id.replace("screen-", "")),
screen_name: (n.data as ScreenNodeData).label || `화면 ${n.id}`,
}));
}, [selectedGroup, nodes]);
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
if (!screen && !selectedGroup) {
return (
@@ -1912,6 +2213,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
nodeTypes={nodeTypes}
minZoom={0.3}
maxZoom={1.5}
@@ -1921,6 +2223,39 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
<Controls position="bottom-right" />
</ReactFlow>
</div>
{/* 화면 노드 설정 모달 */}
{settingModalNode && settingModalNode.nodeType === "screen" && (
<ScreenSettingModal
isOpen={isSettingModalOpen}
onClose={handleSettingModalClose}
screenId={settingModalNode.screenId}
screenName={settingModalNode.screenName}
groupId={selectedGroup?.id}
mainTable={settingModalNode.existingConfig?.mainTable}
mainTableLabel={settingModalNode.tableLabel}
filterTables={settingModalNode.existingConfig?.filterTables}
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
componentCount={0}
onSaveSuccess={handleRefreshVisualization}
/>
)}
{/* 테이블 노드 설정 모달 */}
{settingModalNode && settingModalNode.nodeType === "table" && (
<TableSettingModal
isOpen={isSettingModalOpen}
onClose={handleSettingModalClose}
tableName={settingModalNode.tableName || ""}
tableLabel={settingModalNode.tableLabel}
screenId={settingModalNode.screenId}
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
referencedBy={settingModalNode.existingConfig?.referencedBy}
columns={settingModalNode.existingConfig?.columns}
filterColumns={settingModalNode.existingConfig?.filterColumns}
onSaveSuccess={handleRefreshVisualization}
/>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -458,3 +458,4 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF

View File

@@ -410,3 +410,4 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel