(null);
+ const [formData, setFormData] = useState({
+ source_screen_id: screenId,
+ source_action: "",
+ target_screen_id: 0,
+ target_action: "",
+ flow_type: "unidirectional",
+ flow_label: "",
+ is_active: "Y",
+ });
+
+ // 폼 초기화
+ const resetForm = () => {
+ setFormData({
+ source_screen_id: screenId,
+ source_action: "",
+ target_screen_id: 0,
+ target_action: "",
+ flow_type: "unidirectional",
+ flow_label: "",
+ is_active: "Y",
+ });
+ setEditItem(null);
+ setIsEditing(false);
+ };
+
+ // 수정 모드
+ const handleEdit = (item: DataFlow) => {
+ setEditItem(item);
+ setFormData({
+ source_screen_id: item.source_screen_id,
+ source_action: item.source_action || "",
+ target_screen_id: item.target_screen_id,
+ target_action: item.target_action || "",
+ flow_type: item.flow_type,
+ flow_label: item.flow_label || "",
+ is_active: item.is_active,
+ });
+ setIsEditing(true);
+ };
+
+ // 저장
+ const handleSave = async () => {
+ if (!formData.source_screen_id || !formData.target_screen_id) {
+ toast.error("소스 화면과 타겟 화면을 선택해주세요.");
+ return;
+ }
+
+ try {
+ const payload = {
+ group_id: groupId,
+ ...formData,
+ };
+
+ let response;
+ if (editItem) {
+ response = await updateDataFlow(editItem.id, payload);
+ } else {
+ response = await createDataFlow(payload);
+ }
+
+ if (response.success) {
+ toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
+ resetForm();
+ onReload();
+ onRefreshVisualization?.();
+ } else {
+ toast.error(response.message || "저장에 실패했습니다.");
+ }
+ } catch (error: any) {
+ toast.error(error.message || "저장 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 삭제
+ const handleDelete = async (id: number) => {
+ if (!confirm("정말 삭제하시겠습니까?")) return;
+
+ try {
+ const response = await deleteDataFlow(id);
+ if (response.success) {
+ toast.success("데이터 흐름이 삭제되었습니다.");
+ onReload();
+ onRefreshVisualization?.();
+ } else {
+ toast.error(response.message || "삭제에 실패했습니다.");
+ }
+ } catch (error: any) {
+ toast.error(error.message || "삭제 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 그룹 없음 안내
+ if (!groupId) {
+ return (
+
+
+
그룹 정보가 없습니다
+
+ 데이터 흐름 설정은 화면 그룹 내에서만 사용할 수 있습니다.
+
+
+ );
+ }
+
+ return (
+
+ {/* 입력 폼 */}
+
+
{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
+
+
+ {/* 소스 화면 */}
+
+
+ setFormData(prev => ({ ...prev, source_screen_id: parseInt(v) }))}
+ options={groupScreens.map((s) => ({
+ value: s.screen_id.toString(),
+ label: s.screen_name,
+ }))}
+ placeholder="화면 선택"
+ searchPlaceholder="화면 검색..."
+ />
+
+
+ {/* 소스 액션 */}
+
+
+ setFormData(prev => ({ ...prev, source_action: e.target.value }))}
+ placeholder="예: 행 선택"
+ className="h-9 text-xs"
+ />
+
+
+ {/* 타겟 화면 */}
+
+
+ setFormData(prev => ({ ...prev, target_screen_id: parseInt(v) }))}
+ options={groupScreens
+ .filter(s => s.screen_id !== formData.source_screen_id)
+ .map((s) => ({
+ value: s.screen_id.toString(),
+ label: s.screen_name,
+ }))}
+ placeholder="화면 선택"
+ searchPlaceholder="화면 검색..."
+ />
+
+
+ {/* 흐름 타입 */}
+
+
+ setFormData(prev => ({ ...prev, flow_type: v }))}
+ options={[
+ { value: "unidirectional", label: "단방향" },
+ { value: "bidirectional", label: "양방향" },
+ ]}
+ placeholder="흐름 타입"
+ searchPlaceholder="타입 검색..."
+ />
+
+
+
+
+ {isEditing && (
+
+ )}
+
+
+
+
+ {/* 목록 */}
+
+
+
+
+ 소스 화면
+ 액션
+ 타겟 화면
+ 흐름
+ 작업
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : dataFlows.length === 0 ? (
+
+
+ 등록된 데이터 흐름이 없습니다.
+
+
+ ) : (
+ dataFlows.map((item) => (
+
+
+ {item.source_screen_name || `화면 ${item.source_screen_id}`}
+
+
+ {item.source_action || "-"}
+
+
+ {item.target_screen_name || `화면 ${item.target_screen_id}`}
+
+
+
+ {item.flow_type === "bidirectional" ? "양방향" : "단방향"}
+
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
+
+
+// ============================================================
+// 탭4: 필드-컬럼 매핑 (화면 컴포넌트와 DB 컬럼 연결)
+// ============================================================
+
+interface FieldMappingTabProps {
+ screenId: number;
+ tableName?: string;
+ tableColumns: ColumnTypeInfo[];
+ loading: boolean;
+}
+
+function FieldMappingTab({
+ screenId,
+ tableName,
+ tableColumns,
+ loading,
+}: FieldMappingTabProps) {
+ // 필드 매핑은 screen_layouts.properties에서 관리됨
+ // 이 탭에서는 현재 매핑 상태를 조회하고 편집 가능하게 제공
+
+ return (
+
+
+
+
+ 필드-컬럼 매핑
+
+
+ 화면 컴포넌트와 데이터베이스 컬럼 간의 바인딩을 설정합니다.
+
+ 현재는 화면 디자이너에서 설정된 내용을 확인할 수 있습니다.
+
+
+
+ {/* 테이블 컬럼 목록 */}
+ {tableName && (
+
+
+ 테이블: {tableName}
+
+
+
+
+ 컬럼명
+ 한글명
+ 데이터 타입
+ 웹 타입
+ PK
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : tableColumns.length === 0 ? (
+
+
+ 컬럼 정보가 없습니다.
+
+
+ ) : (
+ tableColumns.slice(0, 20).map((col) => (
+
+ {col.columnName}
+ {col.displayName}
+ {col.dbType}
+
+
+ {col.webType}
+
+
+
+ {col.isPrimaryKey && (
+
+ PK
+
+ )}
+
+
+ ))
+ )}
+
+
+ {tableColumns.length > 20 && (
+
+ + {tableColumns.length - 20}개 더 있음
+
+ )}
+
+ )}
+
+ {!tableName && (
+
+
+
테이블 정보가 없습니다
+
+ 테이블 노드에서 더블클릭하여 필드 매핑을 확인하세요.
+
+
+ )}
+
+ );
+}
+
diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx
index e05789f5..e49aa470 100644
--- a/frontend/components/screen/ScreenNode.tsx
+++ b/frontend/components/screen/ScreenNode.tsx
@@ -569,7 +569,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
// 저장 대상 여부
const hasSaveTarget = saveInfos && saveInfos.length > 0;
-
+
return (
= ({ data }) => {
)}
-
+
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
= {
@@ -86,6 +88,64 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
const [focusedScreenId, setFocusedScreenId] = useState(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
+
+ {/* 화면 노드 설정 모달 */}
+ {settingModalNode && settingModalNode.nodeType === "screen" && (
+
+ )}
+
+ {/* 테이블 노드 설정 모달 */}
+ {settingModalNode && settingModalNode.nodeType === "table" && (
+
+ )}
);
}
diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx
new file mode 100644
index 00000000..4e72a432
--- /dev/null
+++ b/frontend/components/screen/ScreenSettingModal.tsx
@@ -0,0 +1,1073 @@
+"use client";
+
+import React, { useState, useEffect, useCallback, useMemo } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import {
+ Database,
+ Link2,
+ GitBranch,
+ Columns3,
+ Eye,
+ Save,
+ Plus,
+ Pencil,
+ Trash2,
+ RefreshCw,
+ Loader2,
+ Check,
+ ChevronsUpDown,
+ ExternalLink,
+ Table2,
+ ArrowRight,
+ Settings2,
+} from "lucide-react";
+import {
+ getDataFlows,
+ createDataFlow,
+ updateDataFlow,
+ deleteDataFlow,
+ DataFlow,
+} from "@/lib/api/screenGroup";
+import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
+
+// ============================================================
+// 타입 정의
+// ============================================================
+
+interface FilterTableInfo {
+ tableName: string;
+ tableLabel?: string;
+ filterColumns?: string[];
+ joinColumnRefs?: Array<{
+ column: string;
+ refTable: string;
+ refTableLabel?: string;
+ refColumn: string;
+ }>;
+}
+
+interface FieldMappingInfo {
+ targetField: string;
+ sourceField: string;
+ sourceTable?: string;
+ sourceDisplayName?: string;
+ componentType?: string;
+}
+
+interface ScreenSettingModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ screenId: number;
+ screenName: string;
+ groupId?: number;
+ mainTable?: string;
+ mainTableLabel?: string;
+ filterTables?: FilterTableInfo[];
+ fieldMappings?: FieldMappingInfo[];
+ componentCount?: number;
+ onSaveSuccess?: () => void;
+}
+
+// 검색 가능한 Select 컴포넌트
+interface SearchableSelectProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ options: Array<{ value: string; label: string; description?: string }>;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+}
+
+function SearchableSelect({
+ value,
+ onValueChange,
+ options,
+ placeholder = "선택...",
+ disabled = false,
+ className,
+}: SearchableSelectProps) {
+ const [open, setOpen] = useState(false);
+
+ const selectedOption = options.find((opt) => opt.value === value);
+
+ return (
+
+
+
+
+
+
+
+
+
+ 결과 없음
+
+
+ {options.map((option) => (
+ {
+ onValueChange(option.value);
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {option.label}
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// ============================================================
+// 메인 모달 컴포넌트
+// ============================================================
+
+export function ScreenSettingModal({
+ isOpen,
+ onClose,
+ screenId,
+ screenName,
+ groupId,
+ mainTable,
+ mainTableLabel,
+ filterTables = [],
+ fieldMappings = [],
+ componentCount = 0,
+ onSaveSuccess,
+}: ScreenSettingModalProps) {
+ const [activeTab, setActiveTab] = useState("overview");
+ const [loading, setLoading] = useState(false);
+ const [dataFlows, setDataFlows] = useState([]);
+
+ // 데이터 로드
+ const loadData = useCallback(async () => {
+ if (!screenId) return;
+
+ setLoading(true);
+ try {
+ // 데이터 흐름 로드
+ const flowsResponse = await getDataFlows(screenId);
+ if (flowsResponse.success && flowsResponse.data) {
+ setDataFlows(flowsResponse.data);
+ }
+ } catch (error) {
+ console.error("데이터 로드 실패:", error);
+ } finally {
+ setLoading(false);
+ }
+ }, [screenId]);
+
+ useEffect(() => {
+ if (isOpen && screenId) {
+ loadData();
+ }
+ }, [isOpen, screenId, loadData]);
+
+ // 새로고침
+ const handleRefresh = () => {
+ loadData();
+ toast.success("새로고침 완료");
+ };
+
+ return (
+
+ );
+}
+
+// ============================================================
+// 탭 1: 화면 개요
+// ============================================================
+
+interface OverviewTabProps {
+ screenId: number;
+ screenName: string;
+ mainTable?: string;
+ mainTableLabel?: string;
+ filterTables: FilterTableInfo[];
+ fieldMappings: FieldMappingInfo[];
+ componentCount: number;
+ dataFlows: DataFlow[];
+ loading: boolean;
+}
+
+function OverviewTab({
+ screenId,
+ screenName,
+ mainTable,
+ mainTableLabel,
+ filterTables,
+ fieldMappings,
+ componentCount,
+ dataFlows,
+ loading,
+}: OverviewTabProps) {
+ // 통계 계산
+ const stats = useMemo(() => {
+ const totalJoins = filterTables.reduce(
+ (sum, ft) => sum + (ft.joinColumnRefs?.length || 0),
+ 0
+ );
+ const totalFilters = filterTables.reduce(
+ (sum, ft) => sum + (ft.filterColumns?.length || 0),
+ 0
+ );
+
+ return {
+ tableCount: 1 + filterTables.length, // 메인 + 필터
+ fieldCount: fieldMappings.length,
+ joinCount: totalJoins,
+ filterCount: totalFilters,
+ flowCount: dataFlows.length,
+ };
+ }, [filterTables, fieldMappings, dataFlows]);
+
+ return (
+
+ {/* 기본 정보 카드 */}
+
+
+
{stats.tableCount}
+
연결된 테이블
+
+
+
{stats.fieldCount}
+
필드 매핑
+
+
+
{stats.joinCount}
+
조인 설정
+
+
+
{stats.filterCount}
+
필터 컬럼
+
+
+
{stats.flowCount}
+
데이터 흐름
+
+
+
+ {/* 메인 테이블 */}
+
+
+
+ 메인 테이블
+
+ {mainTable ? (
+
+
+
+
{mainTableLabel || mainTable}
+ {mainTableLabel && mainTable !== mainTableLabel && (
+
{mainTable}
+ )}
+
+
+ 메인
+
+
+ ) : (
+
+ 메인 테이블이 설정되지 않았습니다.
+
+ )}
+
+
+ {/* 필터 테이블 */}
+
+
+
+ 필터 테이블 ({filterTables.length}개)
+
+ {filterTables.length > 0 ? (
+
+ {filterTables.map((ft, idx) => (
+
+
+
+
+
{ft.tableLabel || ft.tableName}
+ {ft.tableLabel && ft.tableName !== ft.tableLabel && (
+
{ft.tableName}
+ )}
+
+
+ 필터
+
+
+
+ {/* 조인 정보 */}
+ {ft.joinColumnRefs && ft.joinColumnRefs.length > 0 && (
+
+
조인 설정:
+ {ft.joinColumnRefs.map((join, jIdx) => (
+
+
+ {join.column}
+
+
+
+ {join.refTableLabel || join.refTable}.{join.refColumn}
+
+
+ ))}
+
+ )}
+
+ {/* 필터 컬럼 */}
+ {ft.filterColumns && ft.filterColumns.length > 0 && (
+
+
필터 컬럼:
+
+ {ft.filterColumns.map((col, cIdx) => (
+
+ {col}
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ ) : (
+
+ 필터 테이블이 없습니다.
+
+ )}
+
+
+ {/* 데이터 흐름 요약 */}
+
+
+
+ 데이터 흐름 ({dataFlows.length}개)
+
+ {dataFlows.length > 0 ? (
+
+ {dataFlows.slice(0, 3).map((flow) => (
+
+
+ {flow.flow_type}
+
+
{flow.description || "설명 없음"}
+
+
화면 {flow.target_screen_id}
+
+ ))}
+ {dataFlows.length > 3 && (
+
+ +{dataFlows.length - 3}개 더 있음
+
+ )}
+
+ ) : (
+
+ 설정된 데이터 흐름이 없습니다.
+
+ )}
+
+
+ );
+}
+
+// ============================================================
+// 탭 2: 필드 매핑
+// ============================================================
+
+interface FieldMappingTabProps {
+ screenId: number;
+ mainTable?: string;
+ fieldMappings: FieldMappingInfo[];
+ loading: boolean;
+}
+
+function FieldMappingTab({
+ screenId,
+ mainTable,
+ fieldMappings,
+ loading,
+}: FieldMappingTabProps) {
+ // 컴포넌트 타입별 그룹핑
+ const groupedMappings = useMemo(() => {
+ const grouped: Record = {};
+
+ fieldMappings.forEach((mapping) => {
+ const type = mapping.componentType || "기타";
+ if (!grouped[type]) {
+ grouped[type] = [];
+ }
+ grouped[type].push(mapping);
+ });
+
+ return grouped;
+ }, [fieldMappings]);
+
+ const componentTypes = Object.keys(groupedMappings);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
필드-컬럼 매핑 현황
+
+ 화면 필드가 어떤 테이블 컬럼과 연결되어 있는지 확인합니다.
+
+
+
+ 총 {fieldMappings.length}개 필드
+
+
+
+ {fieldMappings.length === 0 ? (
+
+ ) : (
+
+
+
+
+ #
+ 필드명
+ 테이블
+ 컬럼
+ 컴포넌트 타입
+
+
+
+ {fieldMappings.map((mapping, idx) => (
+
+
+ {idx + 1}
+
+
+ {mapping.targetField}
+
+
+
+ {mapping.sourceTable || mainTable || "-"}
+
+
+
+
+ {mapping.sourceField}
+
+
+
+ {mapping.componentType || "-"}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 컴포넌트 타입별 요약 */}
+ {componentTypes.length > 0 && (
+
+
컴포넌트 타입별 분류
+
+ {componentTypes.map((type) => (
+
+ {type}
+
+ {groupedMappings[type].length}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// ============================================================
+// 탭 3: 데이터 흐름
+// ============================================================
+
+interface DataFlowTabProps {
+ screenId: number;
+ groupId?: number;
+ dataFlows: DataFlow[];
+ loading: boolean;
+ onReload: () => void;
+ onSaveSuccess?: () => void;
+}
+
+function DataFlowTab({
+ screenId,
+ groupId,
+ dataFlows,
+ loading,
+ onReload,
+ onSaveSuccess,
+}: DataFlowTabProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editItem, setEditItem] = useState(null);
+ const [formData, setFormData] = useState({
+ target_screen_id: "",
+ action_type: "navigate",
+ data_mapping: "",
+ flow_type: "forward",
+ description: "",
+ is_active: "Y",
+ });
+
+ // 폼 초기화
+ const resetForm = () => {
+ setFormData({
+ target_screen_id: "",
+ action_type: "navigate",
+ data_mapping: "",
+ flow_type: "forward",
+ description: "",
+ is_active: "Y",
+ });
+ setEditItem(null);
+ setIsEditing(false);
+ };
+
+ // 수정 모드
+ const handleEdit = (item: DataFlow) => {
+ setEditItem(item);
+ setFormData({
+ target_screen_id: String(item.target_screen_id),
+ action_type: item.action_type,
+ data_mapping: item.data_mapping || "",
+ flow_type: item.flow_type,
+ description: item.description || "",
+ is_active: item.is_active,
+ });
+ setIsEditing(true);
+ };
+
+ // 저장
+ const handleSave = async () => {
+ if (!formData.target_screen_id) {
+ toast.error("대상 화면을 선택해주세요.");
+ return;
+ }
+
+ try {
+ const payload = {
+ source_screen_id: screenId,
+ target_screen_id: parseInt(formData.target_screen_id),
+ action_type: formData.action_type,
+ data_mapping: formData.data_mapping || null,
+ flow_type: formData.flow_type,
+ description: formData.description || null,
+ is_active: formData.is_active,
+ };
+
+ let response;
+ if (editItem) {
+ response = await updateDataFlow(editItem.id, payload);
+ } else {
+ response = await createDataFlow(payload);
+ }
+
+ if (response.success) {
+ toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
+ resetForm();
+ onReload();
+ onSaveSuccess?.();
+ } else {
+ toast.error(response.message || "저장에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("저장 오류:", error);
+ toast.error("저장 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 삭제
+ const handleDelete = async (id: number) => {
+ if (!confirm("정말로 삭제하시겠습니까?")) return;
+
+ try {
+ const response = await deleteDataFlow(id);
+ if (response.success) {
+ toast.success("데이터 흐름이 삭제되었습니다.");
+ onReload();
+ onSaveSuccess?.();
+ } else {
+ toast.error(response.message || "삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("삭제 오류:", error);
+ toast.error("삭제 중 오류가 발생했습니다.");
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 입력 폼 */}
+
+
+ {isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
+
+
+
+
+
+
+ setFormData({ ...formData, target_screen_id: e.target.value })
+ }
+ placeholder="화면 ID"
+ className="h-8 text-xs"
+ />
+
+
+
+ setFormData({ ...formData, action_type: v })}
+ options={[
+ { value: "navigate", label: "화면 이동" },
+ { value: "modal", label: "모달 열기" },
+ { value: "callback", label: "콜백" },
+ { value: "refresh", label: "새로고침" },
+ ]}
+ placeholder="액션 선택"
+ />
+
+
+
+ setFormData({ ...formData, flow_type: v })}
+ options={[
+ { value: "forward", label: "전달 (Forward)" },
+ { value: "return", label: "반환 (Return)" },
+ { value: "broadcast", label: "브로드캐스트" },
+ ]}
+ placeholder="흐름 선택"
+ />
+
+
+
+
+
+
+
+
+ {isEditing && (
+
+ )}
+
+
+
+
+ {/* 목록 */}
+
+
+
+
+ 대상 화면
+ 액션
+ 흐름 타입
+ 설명
+ 작업
+
+
+
+ {dataFlows.length === 0 ? (
+
+
+ 등록된 데이터 흐름이 없습니다.
+
+
+ ) : (
+ dataFlows.map((flow) => (
+
+
+ 화면 {flow.target_screen_id}
+
+
+
+ {flow.action_type}
+
+
+
+
+ {flow.flow_type}
+
+
+
+ {flow.description || "-"}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
+
+// ============================================================
+// 탭 4: 화면 프리뷰 (iframe)
+// ============================================================
+
+interface PreviewTabProps {
+ screenId: number;
+ screenName: string;
+}
+
+function PreviewTab({ screenId, screenName }: PreviewTabProps) {
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 화면 URL 생성
+ const previewUrl = useMemo(() => {
+ // 현재 호스트 기반으로 URL 생성
+ if (typeof window !== "undefined") {
+ const baseUrl = window.location.origin;
+ return `${baseUrl}/screens/${screenId}`;
+ }
+ return `/screens/${screenId}`;
+ }, [screenId]);
+
+ const handleIframeLoad = () => {
+ setLoading(false);
+ };
+
+ const handleIframeError = () => {
+ setLoading(false);
+ setError("화면을 불러오는데 실패했습니다.");
+ };
+
+ const openInNewTab = () => {
+ window.open(previewUrl, "_blank");
+ };
+
+ return (
+
+ {/* 상단 툴바 */}
+
+
+
+ 화면 프리뷰
+
+ Screen ID: {screenId}
+
+
+
+
+
+
+
+
+ {/* iframe 영역 */}
+
+ {loading && (
+
+ )}
+
+ {error ? (
+
+
+
+ ⚠️
+
+
{error}
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* 안내 메시지 */}
+
+ * 프리뷰는 실제 화면을 iframe으로 로드합니다. 로그인/권한에 따라 일부 기능이 제한될 수 있습니다.
+
+
+ );
+}
+
+export default ScreenSettingModal;
+
diff --git a/frontend/components/screen/TableSettingModal.tsx b/frontend/components/screen/TableSettingModal.tsx
new file mode 100644
index 00000000..cbed9449
--- /dev/null
+++ b/frontend/components/screen/TableSettingModal.tsx
@@ -0,0 +1,1094 @@
+"use client";
+
+import React, { useState, useEffect, useCallback, useMemo } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import {
+ Database,
+ Link2,
+ Columns3,
+ Key,
+ Save,
+ Plus,
+ Pencil,
+ Trash2,
+ RefreshCw,
+ Loader2,
+ Check,
+ ChevronsUpDown,
+ Table2,
+ ArrowRight,
+ Eye,
+ Settings2,
+} from "lucide-react";
+import {
+ getFieldJoins,
+ createFieldJoin,
+ updateFieldJoin,
+ deleteFieldJoin,
+ FieldJoin,
+} from "@/lib/api/screenGroup";
+import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
+
+// ============================================================
+// 타입 정의
+// ============================================================
+
+interface JoinColumnRef {
+ column: string;
+ refTable: string;
+ refTableLabel?: string;
+ refColumn: string;
+}
+
+interface ReferencedBy {
+ fromTable: string;
+ fromTableLabel?: string;
+ fromColumn: string;
+ toColumn: string;
+}
+
+interface ColumnInfo {
+ column: string;
+ label?: string;
+ type?: string;
+ isPK?: boolean;
+ isFK?: boolean;
+ refTable?: string;
+ refColumn?: string;
+}
+
+interface TableSettingModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ tableName: string;
+ tableLabel?: string;
+ screenId?: number;
+ joinColumnRefs?: JoinColumnRef[];
+ referencedBy?: ReferencedBy[];
+ columns?: ColumnInfo[];
+ filterColumns?: string[];
+ onSaveSuccess?: () => void;
+}
+
+// 검색 가능한 Select 컴포넌트
+interface SearchableSelectProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ options: Array<{ value: string; label: string; description?: string }>;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+}
+
+function SearchableSelect({
+ value,
+ onValueChange,
+ options,
+ placeholder = "선택...",
+ disabled = false,
+ className,
+}: SearchableSelectProps) {
+ const [open, setOpen] = useState(false);
+
+ const selectedOption = options.find((opt) => opt.value === value);
+
+ return (
+
+
+
+
+
+
+
+
+
+ 결과 없음
+
+
+ {options.map((option) => (
+ {
+ onValueChange(option.value);
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {option.label}
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// ============================================================
+// 메인 모달 컴포넌트
+// ============================================================
+
+export function TableSettingModal({
+ isOpen,
+ onClose,
+ tableName,
+ tableLabel,
+ screenId,
+ joinColumnRefs = [],
+ referencedBy = [],
+ columns = [],
+ filterColumns = [],
+ onSaveSuccess,
+}: TableSettingModalProps) {
+ const [activeTab, setActiveTab] = useState("info");
+ const [loading, setLoading] = useState(false);
+ const [tableColumns, setTableColumns] = useState([]);
+ const [tables, setTables] = useState([]);
+ const [fieldJoins, setFieldJoins] = useState([]);
+
+ // 테이블 컬럼 정보 로드
+ const loadTableColumns = useCallback(async () => {
+ if (!tableName) return;
+
+ setLoading(true);
+ try {
+ // 테이블 목록 로드
+ const tablesResponse = await tableManagementApi.getTables();
+ if (tablesResponse.success && tablesResponse.data) {
+ setTables(tablesResponse.data);
+ }
+
+ // 테이블 컬럼 로드
+ const columnsResponse = await tableManagementApi.getTableColumns(tableName);
+ if (columnsResponse.success && columnsResponse.data) {
+ setTableColumns(columnsResponse.data);
+ }
+
+ // 필드 조인 로드 (screenId가 있는 경우)
+ if (screenId) {
+ const joinsResponse = await getFieldJoins(screenId);
+ if (joinsResponse.success && joinsResponse.data) {
+ // 이 테이블과 관련된 조인만 필터링
+ const relevantJoins = joinsResponse.data.filter(
+ (j) => j.save_table === tableName || j.join_table === tableName
+ );
+ setFieldJoins(relevantJoins);
+ }
+ }
+ } catch (error) {
+ console.error("테이블 정보 로드 실패:", error);
+ toast.error("테이블 정보를 불러오는데 실패했습니다.");
+ } finally {
+ setLoading(false);
+ }
+ }, [tableName, screenId]);
+
+ useEffect(() => {
+ if (isOpen && tableName) {
+ loadTableColumns();
+ }
+ }, [isOpen, tableName, loadTableColumns]);
+
+ // 새로고침
+ const handleRefresh = () => {
+ loadTableColumns();
+ toast.success("새로고침 완료");
+ };
+
+ return (
+
+ );
+}
+
+// ============================================================
+// 탭 1: 테이블 정보
+// ============================================================
+
+interface TableInfoTabProps {
+ tableName: string;
+ tableLabel?: string;
+ columns: ColumnInfo[];
+ tableColumns: ColumnTypeInfo[];
+ filterColumns: string[];
+ loading: boolean;
+}
+
+function TableInfoTab({
+ tableName,
+ tableLabel,
+ columns,
+ tableColumns,
+ filterColumns,
+ loading,
+}: TableInfoTabProps) {
+ // 컬럼 정보 통합 (기존 columns + API에서 가져온 tableColumns)
+ const mergedColumns = useMemo(() => {
+ const columnsMap = new Map();
+
+ // 먼저 기존 columns 추가
+ columns.forEach((col) => {
+ columnsMap.set(col.column, col);
+ });
+
+ // API에서 가져온 컬럼 정보로 보강
+ tableColumns.forEach((tcol) => {
+ const existing = columnsMap.get(tcol.column_name);
+ if (existing) {
+ columnsMap.set(tcol.column_name, {
+ ...existing,
+ type: tcol.data_type,
+ isPK: tcol.is_primary_key,
+ isFK: tcol.is_foreign_key,
+ refTable: tcol.references?.table,
+ refColumn: tcol.references?.column,
+ label: existing.label || tcol.column_name,
+ });
+ } else {
+ columnsMap.set(tcol.column_name, {
+ column: tcol.column_name,
+ label: tcol.column_name,
+ type: tcol.data_type,
+ isPK: tcol.is_primary_key,
+ isFK: tcol.is_foreign_key,
+ refTable: tcol.references?.table,
+ refColumn: tcol.references?.column,
+ });
+ }
+ });
+
+ return Array.from(columnsMap.values());
+ }, [columns, tableColumns]);
+
+ // PK, FK 분류
+ const pkColumns = mergedColumns.filter((c) => c.isPK);
+ const fkColumns = mergedColumns.filter((c) => c.isFK);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 기본 정보 */}
+
+
+
{mergedColumns.length}
+
전체 컬럼
+
+
+
{pkColumns.length}
+
Primary Key
+
+
+
{fkColumns.length}
+
Foreign Key
+
+
+
{filterColumns.length}
+
필터 컬럼
+
+
+
+ {/* 필터 컬럼 */}
+ {filterColumns.length > 0 && (
+
+
필터 컬럼
+
+ {filterColumns.map((col, idx) => (
+
+ {col}
+
+ ))}
+
+
+ )}
+
+ {/* 컬럼 목록 */}
+
+
컬럼 목록
+
+
+
+
+ #
+ 컬럼명
+ 데이터 타입
+ 키
+ 참조
+
+
+
+ {mergedColumns.length === 0 ? (
+
+
+ 컬럼 정보를 불러올 수 없습니다.
+
+
+ ) : (
+ mergedColumns.map((col, idx) => (
+
+
+ {idx + 1}
+
+
+ {col.label || col.column}
+ {col.label && col.column !== col.label && (
+ ({col.column})
+ )}
+
+
+ {col.type || "-"}
+
+
+
+ {col.isPK && (
+
+
+ PK
+
+ )}
+ {col.isFK && (
+
+
+ FK
+
+ )}
+
+
+
+ {col.refTable && col.refColumn ? (
+
+
+ {col.refTable}.{col.refColumn}
+
+ ) : (
+ "-"
+ )}
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
+
+// ============================================================
+// 탭 2: 조인 설정
+// ============================================================
+
+interface JoinSettingTabProps {
+ tableName: string;
+ tableLabel?: string;
+ screenId?: number;
+ joinColumnRefs: JoinColumnRef[];
+ fieldJoins: FieldJoin[];
+ tables: TableInfo[];
+ tableColumns: ColumnTypeInfo[];
+ loading: boolean;
+ onReload: () => void;
+ onSaveSuccess?: () => void;
+}
+
+function JoinSettingTab({
+ tableName,
+ tableLabel,
+ screenId,
+ joinColumnRefs,
+ fieldJoins,
+ tables,
+ tableColumns,
+ loading,
+ onReload,
+ onSaveSuccess,
+}: JoinSettingTabProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editItem, setEditItem] = useState(null);
+ const [formData, setFormData] = useState({
+ save_column: "",
+ join_table: "",
+ join_column: "",
+ display_column: "",
+ join_type: "LEFT",
+ });
+ const [targetColumns, setTargetColumns] = useState([]);
+
+ // 조인 테이블 변경 시 컬럼 로드
+ const loadTargetColumns = useCallback(async (targetTable: string) => {
+ if (!targetTable) {
+ setTargetColumns([]);
+ return;
+ }
+
+ try {
+ const response = await tableManagementApi.getTableColumns(targetTable);
+ if (response.success && response.data) {
+ setTargetColumns(response.data);
+ }
+ } catch (error) {
+ console.error("대상 테이블 컬럼 로드 실패:", error);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (formData.join_table) {
+ loadTargetColumns(formData.join_table);
+ }
+ }, [formData.join_table, loadTargetColumns]);
+
+ // 폼 초기화
+ const resetForm = () => {
+ setFormData({
+ save_column: "",
+ join_table: "",
+ join_column: "",
+ display_column: "",
+ join_type: "LEFT",
+ });
+ setEditItem(null);
+ setIsEditing(false);
+ setTargetColumns([]);
+ };
+
+ // 수정 모드
+ const handleEdit = (item: FieldJoin) => {
+ setEditItem(item);
+ setFormData({
+ save_column: item.save_column,
+ join_table: item.join_table,
+ join_column: item.join_column,
+ display_column: item.display_column || "",
+ join_type: item.join_type,
+ });
+ setIsEditing(true);
+ loadTargetColumns(item.join_table);
+ };
+
+ // 디자이너 설정을 DB로 저장
+ const handleSaveDesignerJoin = async (join: JoinColumnRef) => {
+ if (!screenId) {
+ toast.error("화면 ID가 필요합니다.");
+ return;
+ }
+
+ try {
+ const payload = {
+ screen_id: screenId,
+ save_table: tableName,
+ save_column: join.column,
+ join_table: join.refTable,
+ join_column: join.refColumn,
+ display_column: "",
+ join_type: "LEFT",
+ is_active: "Y",
+ };
+
+ const response = await createFieldJoin(payload);
+ if (response.success) {
+ toast.success("조인 설정이 DB에 저장되었습니다.");
+ onReload();
+ onSaveSuccess?.();
+ } else {
+ toast.error(response.message || "저장에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("저장 오류:", error);
+ toast.error("저장 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 저장
+ const handleSave = async () => {
+ if (!screenId) {
+ toast.error("화면 ID가 필요합니다.");
+ return;
+ }
+
+ if (!formData.save_column || !formData.join_table || !formData.join_column) {
+ toast.error("필수 항목을 모두 입력해주세요.");
+ return;
+ }
+
+ try {
+ const payload = {
+ screen_id: screenId,
+ save_table: tableName,
+ save_column: formData.save_column,
+ join_table: formData.join_table,
+ join_column: formData.join_column,
+ display_column: formData.display_column || null,
+ join_type: formData.join_type,
+ is_active: "Y",
+ };
+
+ let response;
+ if (editItem) {
+ response = await updateFieldJoin(editItem.id, payload);
+ } else {
+ response = await createFieldJoin(payload);
+ }
+
+ if (response.success) {
+ toast.success(editItem ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
+ resetForm();
+ onReload();
+ onSaveSuccess?.();
+ } else {
+ toast.error(response.message || "저장에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("저장 오류:", error);
+ toast.error("저장 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 삭제
+ const handleDelete = async (id: number) => {
+ if (!confirm("정말로 삭제하시겠습니까?")) return;
+
+ try {
+ const response = await deleteFieldJoin(id);
+ if (response.success) {
+ toast.success("조인 설정이 삭제되었습니다.");
+ onReload();
+ onSaveSuccess?.();
+ } else {
+ toast.error(response.message || "삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("삭제 오류:", error);
+ toast.error("삭제 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 통합 조인 목록 (디자이너 + DB)
+ const unifiedJoins = useMemo(() => {
+ // DB에서 가져온 조인
+ const dbJoins = fieldJoins.map((j) => ({
+ ...j,
+ id: j.id,
+ source: "db" as const,
+ }));
+
+ // 디자이너 조인 (DB에 없는 것만)
+ const dbJoinKeys = new Set(
+ fieldJoins.map((j) => `${j.save_column}:${j.join_table}:${j.join_column}`)
+ );
+ const designerJoins = joinColumnRefs
+ .filter(
+ (j) => !dbJoinKeys.has(`${j.column}:${j.refTable}:${j.refColumn}`)
+ )
+ .map((j, idx) => ({
+ id: `designer-${idx}`,
+ source: "designer" as const,
+ save_table: tableName,
+ save_column: j.column,
+ join_table: j.refTable,
+ join_table_label: j.refTableLabel,
+ join_column: j.refColumn,
+ display_column: "",
+ join_type: "LEFT",
+ }));
+
+ return [...designerJoins, ...dbJoins];
+ }, [fieldJoins, joinColumnRefs, tableName]);
+
+ // 테이블 옵션
+ const tableOptions = useMemo(
+ () =>
+ tables.map((t) => ({
+ value: t.table_name,
+ label: t.table_name,
+ })),
+ [tables]
+ );
+
+ // 현재 테이블 컬럼 옵션
+ const columnOptions = useMemo(
+ () =>
+ tableColumns.map((c) => ({
+ value: c.column_name,
+ label: c.column_name,
+ description: c.data_type,
+ })),
+ [tableColumns]
+ );
+
+ // 대상 테이블 컬럼 옵션
+ const targetColumnOptions = useMemo(
+ () =>
+ targetColumns.map((c) => ({
+ value: c.column_name,
+ label: c.column_name,
+ description: c.data_type,
+ })),
+ [targetColumns]
+ );
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 입력 폼 */}
+ {screenId && (
+
+
+ {isEditing ? "조인 설정 수정" : "새 조인 설정 추가"}
+
+
+
+
+
+ setFormData({ ...formData, save_column: v })}
+ options={columnOptions}
+ placeholder="컬럼 선택"
+ />
+
+
+
+
+ setFormData({ ...formData, join_table: v, join_column: "", display_column: "" })
+ }
+ options={tableOptions}
+ placeholder="테이블 선택"
+ />
+
+
+
+ setFormData({ ...formData, join_column: v })}
+ options={targetColumnOptions}
+ placeholder="컬럼 선택"
+ disabled={!formData.join_table}
+ />
+
+
+
+ setFormData({ ...formData, display_column: v })}
+ options={targetColumnOptions}
+ placeholder="선택 (옵션)"
+ disabled={!formData.join_table}
+ />
+
+
+
+ setFormData({ ...formData, join_type: v })}
+ options={[
+ { value: "LEFT", label: "LEFT JOIN" },
+ { value: "INNER", label: "INNER JOIN" },
+ { value: "RIGHT", label: "RIGHT JOIN" },
+ ]}
+ placeholder="타입 선택"
+ />
+
+
+
+
+ {isEditing && (
+
+ )}
+
+
+
+ )}
+
+ {/* 목록 */}
+
+
+
+
+ 출처
+ 현재 컬럼
+ 조인 테이블
+ 조인 컬럼
+ 타입
+ 작업
+
+
+
+ {unifiedJoins.length === 0 ? (
+
+
+ 등록된 조인 설정이 없습니다.
+
+
+ ) : (
+ unifiedJoins.map((item) => (
+
+
+
+ {item.source === "designer" ? "화면" : "DB"}
+
+
+
+ {item.save_column}
+
+
+ {"join_table_label" in item && item.join_table_label
+ ? item.join_table_label
+ : item.join_table}
+
+ {item.join_column}
+
+
+ {item.join_type}
+
+
+
+
+ {item.source === "designer" ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* 안내 */}
+
+ * 화면: 화면 디자이너에서 설정됨 (DB 저장으로 변환 가능) | * DB: 데이터베이스에 저장됨 (수정/삭제 가능)
+
+
+ );
+}
+
+// ============================================================
+// 탭 3: 참조 관계
+// ============================================================
+
+interface ReferenceTabProps {
+ tableName: string;
+ tableLabel?: string;
+ referencedBy: ReferencedBy[];
+ joinColumnRefs: JoinColumnRef[];
+ loading: boolean;
+}
+
+function ReferenceTab({
+ tableName,
+ tableLabel,
+ referencedBy,
+ joinColumnRefs,
+ loading,
+}: ReferenceTabProps) {
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 이 테이블이 참조하는 테이블 */}
+
+
+
+ 이 테이블이 참조하는 테이블 ({joinColumnRefs.length}개)
+
+ {joinColumnRefs.length > 0 ? (
+
+ {joinColumnRefs.map((ref, idx) => (
+
+
+ {ref.column}
+
+
+
+
+ {ref.refTableLabel || ref.refTable}
+
+ .{ref.refColumn}
+
+
+ ))}
+
+ ) : (
+
+ 참조하는 테이블이 없습니다.
+
+ )}
+
+
+ {/* 이 테이블을 참조하는 테이블 */}
+
+
+
+ 이 테이블을 참조하는 테이블 ({referencedBy.length}개)
+
+ {referencedBy.length > 0 ? (
+
+ {referencedBy.map((ref, idx) => (
+
+
+
+ {ref.fromTableLabel || ref.fromTable}
+
+ .{ref.fromColumn}
+
+
+
+ {ref.toColumn}
+
+
+ ))}
+
+ ) : (
+
+ 이 테이블을 참조하는 테이블이 없습니다.
+
+ )}
+
+
+ );
+}
+
+export default TableSettingModal;
+
diff --git a/frontend/components/screen/panels/DataFlowPanel.tsx b/frontend/components/screen/panels/DataFlowPanel.tsx
index 97a62791..40609835 100644
--- a/frontend/components/screen/panels/DataFlowPanel.tsx
+++ b/frontend/components/screen/panels/DataFlowPanel.tsx
@@ -458,3 +458,4 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF
+
diff --git a/frontend/components/screen/panels/FieldJoinPanel.tsx b/frontend/components/screen/panels/FieldJoinPanel.tsx
index df44ebb0..7ec2b33a 100644
--- a/frontend/components/screen/panels/FieldJoinPanel.tsx
+++ b/frontend/components/screen/panels/FieldJoinPanel.tsx
@@ -410,3 +410,4 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
+