diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 7a887bdb..57a77aa1 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -677,51 +677,100 @@ export class NodeFlowExecutionService { node: FlowNode, context: ExecutionContext ): Promise { - const { connectionId, tableName, schema, whereConditions } = node.data; + const { connectionId, tableName, schema, whereConditions, dataSourceType } = + node.data; - if (!connectionId || !tableName) { - throw new Error("외부 DB 연결 정보 또는 테이블명이 설정되지 않았습니다."); - } + // 🆕 노드의 dataSourceType 확인 (기본값: context-data) + const nodeDataSourceType = dataSourceType || "context-data"; - logger.info(`🔌 외부 DB 소스 조회: ${connectionId}.${tableName}`); + logger.info( + `🔌 외부 DB 소스 노드 실행: ${connectionId}.${tableName}, dataSourceType=${nodeDataSourceType}` + ); - try { - // 연결 풀 서비스 임포트 (동적 임포트로 순환 참조 방지) - const { ExternalDbConnectionPoolService } = await import( - "./externalDbConnectionPoolService" - ); - const poolService = ExternalDbConnectionPoolService.getInstance(); - - // 스키마 접두사 처리 - const schemaPrefix = schema ? `${schema}.` : ""; - const fullTableName = `${schemaPrefix}${tableName}`; - - // WHERE 절 생성 - let sql = `SELECT * FROM ${fullTableName}`; - let params: any[] = []; - - if (whereConditions && whereConditions.length > 0) { - const whereResult = this.buildWhereClause(whereConditions); - sql += ` ${whereResult.clause}`; - params = whereResult.values; + // 1. context-data 모드: 외부에서 주입된 데이터 사용 + if (nodeDataSourceType === "context-data") { + if ( + context.sourceData && + Array.isArray(context.sourceData) && + context.sourceData.length > 0 + ) { + logger.info( + `📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건` + ); + return context.sourceData; } - logger.info(`📊 외부 DB 쿼리 실행: ${sql}`); - - // 연결 풀을 통해 쿼리 실행 - const result = await poolService.executeQuery(connectionId, sql, params); - - logger.info( - `✅ 외부 DB 소스 조회 완료: ${tableName}, ${result.length}건` - ); - - return result; - } catch (error: any) { - logger.error(`❌ 외부 DB 소스 조회 실패:`, error); - throw new Error( - `외부 DB 조회 실패 (연결 ID: ${connectionId}): ${error.message}` + logger.warn( + `⚠️ context-data 모드이지만 전달된 데이터가 없습니다. 빈 배열 반환.` ); + return []; } + + // 2. table-all 모드: 외부 DB 테이블 전체 데이터 조회 + if (nodeDataSourceType === "table-all") { + if (!connectionId || !tableName) { + throw new Error( + "외부 DB 연결 정보 또는 테이블명이 설정되지 않았습니다." + ); + } + + try { + // 연결 풀 서비스 임포트 (동적 임포트로 순환 참조 방지) + const { ExternalDbConnectionPoolService } = await import( + "./externalDbConnectionPoolService" + ); + const poolService = ExternalDbConnectionPoolService.getInstance(); + + // 스키마 접두사 처리 + const schemaPrefix = schema ? `${schema}.` : ""; + const fullTableName = `${schemaPrefix}${tableName}`; + + // WHERE 절 생성 + let sql = `SELECT * FROM ${fullTableName}`; + let params: any[] = []; + + if (whereConditions && whereConditions.length > 0) { + const whereResult = this.buildWhereClause(whereConditions); + sql += ` ${whereResult.clause}`; + params = whereResult.values; + } + + logger.info(`📊 외부 DB 쿼리 실행: ${sql}`); + + // 연결 풀을 통해 쿼리 실행 + const result = await poolService.executeQuery( + connectionId, + sql, + params + ); + + logger.info( + `✅ 외부 DB 전체 데이터 조회 완료: ${tableName}, ${result.length}건` + ); + + return result; + } catch (error: any) { + logger.error(`❌ 외부 DB 소스 조회 실패:`, error); + throw new Error( + `외부 DB 조회 실패 (연결 ID: ${connectionId}): ${error.message}` + ); + } + } + + // 3. 알 수 없는 모드 (기본값으로 처리) + logger.warn( + `⚠️ 알 수 없는 dataSourceType: ${nodeDataSourceType}, context-data로 처리` + ); + + if ( + context.sourceData && + Array.isArray(context.sourceData) && + context.sourceData.length > 0 + ) { + return context.sourceData; + } + + return []; } /** @@ -731,40 +780,71 @@ export class NodeFlowExecutionService { node: FlowNode, context: ExecutionContext ): Promise { - // 🔥 외부에서 주입된 데이터가 있으면 우선 사용 + const { tableName, schema, whereConditions, dataSourceType } = node.data; + + // 🆕 노드의 dataSourceType 확인 (기본값: context-data) + const nodeDataSourceType = dataSourceType || "context-data"; + + logger.info( + `📊 테이블 소스 노드 실행: ${tableName}, dataSourceType=${nodeDataSourceType}` + ); + + // 1. context-data 모드: 외부에서 주입된 데이터 사용 + if (nodeDataSourceType === "context-data") { + if ( + context.sourceData && + Array.isArray(context.sourceData) && + context.sourceData.length > 0 + ) { + logger.info( + `📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건` + ); + return context.sourceData; + } + + logger.warn( + `⚠️ context-data 모드이지만 전달된 데이터가 없습니다. 빈 배열 반환.` + ); + return []; + } + + // 2. table-all 모드: 테이블 전체 데이터 조회 + if (nodeDataSourceType === "table-all") { + if (!tableName) { + logger.warn("⚠️ 테이블 소스 노드에 테이블명이 없습니다."); + return []; + } + + const schemaPrefix = schema ? `${schema}.` : ""; + const whereResult = whereConditions + ? this.buildWhereClause(whereConditions) + : { clause: "", values: [] }; + + const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`; + + const result = await query(sql, whereResult.values); + + logger.info( + `📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}건` + ); + + return result; + } + + // 3. 알 수 없는 모드 (기본값으로 처리) + logger.warn( + `⚠️ 알 수 없는 dataSourceType: ${nodeDataSourceType}, context-data로 처리` + ); + if ( context.sourceData && Array.isArray(context.sourceData) && context.sourceData.length > 0 ) { - logger.info( - `📊 외부 주입 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건` - ); return context.sourceData; } - // 외부 데이터가 없으면 DB 쿼리 실행 - const { tableName, schema, whereConditions } = node.data; - - if (!tableName) { - logger.warn( - "⚠️ 테이블 소스 노드에 테이블명이 없고, 외부 데이터도 없습니다." - ); - return []; - } - - const schemaPrefix = schema ? `${schema}.` : ""; - const whereResult = whereConditions - ? this.buildWhereClause(whereConditions) - : { clause: "", values: [] }; - - const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`; - - const result = await query(sql, whereResult.values); - - logger.info(`📊 테이블 소스 조회: ${tableName}, ${result.length}건`); - - return result; + return []; } /** @@ -1277,9 +1357,16 @@ export class NodeFlowExecutionService { } }); - const whereResult = this.buildWhereClause( + // 🆕 WHERE 조건 자동 보강: Primary Key 추가 + const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( whereConditions, data, + targetTable + ); + + const whereResult = this.buildWhereClause( + enhancedWhereConditions, + data, paramIndex ); @@ -1310,7 +1397,7 @@ export class NodeFlowExecutionService { return updatedDataArray; }; - // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 + // 🔥 클라이언트가 전달되었으면 사용, 없으면 독립 트랜잭션 생성 if (client) { return executeUpdate(client); } else { @@ -1605,7 +1692,15 @@ export class NodeFlowExecutionService { for (const data of dataArray) { console.log("🔍 WHERE 조건 처리 중..."); - const whereResult = this.buildWhereClause(whereConditions, data, 1); + + // 🆕 WHERE 조건 자동 보강: Primary Key 추가 + const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + + const whereResult = this.buildWhereClause(enhancedWhereConditions, data, 1); const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`; @@ -1629,7 +1724,7 @@ export class NodeFlowExecutionService { return deletedDataArray; }; - // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 + // 🔥 클라이언트가 전달되었으면 사용, 없으면 독립 트랜잭션 생성 if (client) { return executeDelete(client); } else { @@ -2439,6 +2534,105 @@ export class NodeFlowExecutionService { /** * WHERE 절 생성 */ + /** + * 테이블의 Primary Key 컬럼 조회 (내부 DB - PostgreSQL) + */ + private static async getPrimaryKeyColumns( + tableName: string, + schema: string = "public" + ): Promise { + const sql = ` + SELECT a.attname AS column_name + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass + AND i.indisprimary + ORDER BY array_position(i.indkey, a.attnum); + `; + + const fullTableName = schema ? `${schema}.${tableName}` : tableName; + + try { + const result = await query(sql, [fullTableName]); + const pkColumns = result.map((row: any) => row.column_name); + + if (pkColumns.length > 0) { + console.log(`🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}`); + } else { + console.log(`⚠️ 테이블 ${tableName}에 Primary Key가 없습니다`); + } + + return pkColumns; + } catch (error) { + console.error(`❌ Primary Key 조회 실패 (${tableName}):`, error); + return []; + } + } + + /** + * WHERE 조건에 Primary Key 자동 추가 (컨텍스트 데이터 사용 시) + * + * 테이블의 실제 Primary Key를 자동으로 감지하여 WHERE 조건에 추가 + */ + private static async enhanceWhereConditionsWithPK( + whereConditions: any[], + data: any, + tableName: string, + schema: string = "public" + ): Promise { + if (!data) { + console.log("⚠️ 입력 데이터가 없어 WHERE 조건 자동 추가 불가"); + return whereConditions || []; + } + + // 🔑 테이블의 실제 Primary Key 컬럼 조회 + const pkColumns = await this.getPrimaryKeyColumns(tableName, schema); + + if (pkColumns.length === 0) { + console.log(`⚠️ 테이블 ${tableName}에 Primary Key가 없어 자동 추가 불가`); + return whereConditions || []; + } + + // 🔍 데이터에 모든 PK 컬럼이 있는지 확인 + const missingPKColumns = pkColumns.filter(col => + data[col] === undefined || data[col] === null + ); + + if (missingPKColumns.length > 0) { + console.log( + `⚠️ 입력 데이터에 Primary Key 컬럼이 없어 자동 추가 불가: ${missingPKColumns.join(", ")}` + ); + return whereConditions || []; + } + + // 🔍 이미 WHERE 조건에 모든 PK가 포함되어 있는지 확인 + const existingFields = new Set( + (whereConditions || []).map((cond: any) => cond.field) + ); + const allPKsExist = pkColumns.every(col => + existingFields.has(col) || existingFields.has(`${tableName}.${col}`) + ); + + if (allPKsExist) { + console.log("✅ WHERE 조건에 이미 모든 Primary Key 포함, 추가하지 않음"); + return whereConditions || []; + } + + // 🔥 Primary Key 조건들을 맨 앞에 추가 + const pkConditions = pkColumns.map(col => ({ + field: col, + operator: 'EQUALS', + value: data[col] + })); + + const enhanced = [...pkConditions, ...(whereConditions || [])]; + + const pkValues = pkColumns.map(col => `${col} = ${data[col]}`).join(", "); + console.log(`🔑 WHERE 조건에 Primary Key 자동 추가: ${pkValues}`); + + return enhanced; + } + private static buildWhereClause( conditions: any[], data?: any, diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 658bc64c..21987359 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -423,6 +423,7 @@ export default function ScreenViewPage() { {}} screenId={screenId} diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index bd7f8e87..8e4abb39 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -28,9 +28,9 @@ export function PropertiesPanel() { const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null; return ( -
+
{/* 헤더 */} -
+

속성

{selectedNode && ( @@ -42,8 +42,15 @@ export function PropertiesPanel() {
- {/* 내용 */} -
+ {/* 내용 - 스크롤 가능 영역 */} +
{selectedNodes.length === 0 ? (
diff --git a/frontend/components/dataflow/node-editor/panels/properties/CommentProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/CommentProperties.tsx index a751fb20..0636210a 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/CommentProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/CommentProperties.tsx @@ -29,7 +29,7 @@ export function CommentProperties({ nodeId, data }: CommentPropertiesProps) { }; return ( -
+
주석 diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index 6e80c927..3a9fa1a7 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -183,26 +183,39 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }; const handleRemoveCondition = (index: number) => { - setConditions(conditions.filter((_, i) => i !== index)); + const newConditions = conditions.filter((_, i) => i !== index); + setConditions(newConditions); + updateNode(nodeId, { + conditions: newConditions, + }); + }; + + const handleDisplayNameChange = (newDisplayName: string) => { + setDisplayName(newDisplayName); + updateNode(nodeId, { + displayName: newDisplayName, + }); }; const handleConditionChange = (index: number, field: string, value: any) => { const newConditions = [...conditions]; newConditions[index] = { ...newConditions[index], [field]: value }; setConditions(newConditions); + updateNode(nodeId, { + conditions: newConditions, + }); }; - const handleSave = () => { + const handleLogicChange = (newLogic: "AND" | "OR") => { + setLogic(newLogic); updateNode(nodeId, { - displayName, - conditions, - logic, + logic: newLogic, }); }; return ( -
+
{/* 기본 정보 */}

기본 정보

@@ -215,7 +228,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setDisplayName(e.target.value)} + onChange={(e) => handleDisplayNameChange(e.target.value)} className="mt-1" placeholder="노드 표시 이름" /> @@ -225,7 +238,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) - @@ -386,12 +399,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) )}
- {/* 저장 버튼 */} -
- -
{/* 안내 */}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx index 5973df21..f2af6d21 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx @@ -359,7 +359,7 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie return ( -
+
{/* 헤더 */}
@@ -453,13 +453,6 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie )}
- {/* 적용 버튼 */} -
- -

✅ 변경 사항이 즉시 노드에 반영됩니다.

-
); diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx index 29e4e86b..09011bf5 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx @@ -217,7 +217,7 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP return ( -
+
{/* 경고 */}
@@ -706,9 +706,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP )}
-
diff --git a/frontend/components/dataflow/node-editor/panels/properties/ExternalDBSourceProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ExternalDBSourceProperties.tsx index b3f36238..9dadabd5 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ExternalDBSourceProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ExternalDBSourceProperties.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from "react"; -import { Database, RefreshCw } from "lucide-react"; +import { Database, RefreshCw, Table, FileText } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -43,6 +43,11 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro const [selectedConnectionId, setSelectedConnectionId] = useState(data.connectionId); const [tableName, setTableName] = useState(data.tableName); const [schema, setSchema] = useState(data.schema || ""); + + // 🆕 데이터 소스 타입 (기본값: context-data) + const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">( + (data as any).dataSourceType || "context-data" + ); const [connections, setConnections] = useState([]); const [tables, setTables] = useState([]); @@ -200,21 +205,26 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro }); }; - const handleSave = () => { + const handleDisplayNameChange = (newDisplayName: string) => { + setDisplayName(newDisplayName); updateNode(nodeId, { - displayName, - connectionId: selectedConnectionId, - connectionName: selectedConnection?.connection_name || "", - tableName, - schema, - dbType: selectedConnection?.db_type, + displayName: newDisplayName, }); - toast.success("설정이 저장되었습니다."); + }; + + /** + * 🆕 데이터 소스 타입 변경 핸들러 + */ + const handleDataSourceTypeChange = (newType: "context-data" | "table-all") => { + setDataSourceType(newType); + updateNode(nodeId, { + dataSourceType: newType, + }); + console.log(`✅ 데이터 소스 타입 변경: ${newType}`); }; return ( - -
+
{/* DB 타입 정보 */}
setDisplayName(e.target.value)} + onChange={(e) => handleDisplayNameChange(e.target.value)} className="mt-1" placeholder="노드 표시 이름" /> @@ -340,6 +350,64 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
)} + {/* 🆕 데이터 소스 설정 */} + {tableName && ( +
+

데이터 소스 설정

+ +
+
+ + + + {/* 설명 텍스트 */} +
+ {dataSourceType === "context-data" ? ( + <> +

💡 컨텍스트 데이터 모드

+

버튼 실행 시 전달된 데이터를 사용합니다.

+ + ) : ( + <> +

📊 테이블 전체 데이터 모드

+

외부 DB의 **모든 행**을 직접 조회합니다.

+

⚠️ 대량 데이터 시 성능 주의

+ + )} +
+
+
+
+ )} + {/* 컬럼 정보 */} {columns.length > 0 && (
@@ -347,14 +415,16 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro {loadingColumns ? (

컬럼 목록 로딩 중... ⏳

) : ( -
+
{columns.map((col, index) => (
- {col.column_name} - {col.data_type} + + {col.column_name} + + {col.data_type}
))}
@@ -362,14 +432,9 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
)} - -
💡 외부 DB 연결은 "외부 DB 연결 관리" 메뉴에서 미리 설정해야 합니다.
-
- +
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 84a55270..6bd7c124 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -451,31 +451,22 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP }; const handleAddMapping = () => { - setFieldMappings([ + const newMappings = [ ...fieldMappings, { sourceField: null, targetField: "", staticValue: undefined, }, - ]); + ]; + setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); }; const handleRemoveMapping = (index: number) => { const newMappings = fieldMappings.filter((_, i) => i !== index); setFieldMappings(newMappings); - - // 즉시 반영 - updateNode(nodeId, { - displayName, - targetTable, - fieldMappings: newMappings, - options: { - batchSize: batchSize ? parseInt(batchSize) : undefined, - ignoreErrors, - ignoreDuplicates, - }, - }); + updateNode(nodeId, { fieldMappings: newMappings }); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -490,28 +481,71 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP sourceFieldLabel: sourceField?.label, }; } else if (field === "targetField") { - const targetColumn = targetColumns.find((c) => c.columnName === value); + const targetColumn = (() => { + if (targetType === "internal") { + return targetColumns.find((col) => col.column_name === value); + } else if (targetType === "external") { + return externalColumns.find((col) => col.column_name === value); + } + return null; + })(); + newMappings[index] = { ...newMappings[index], targetField: value, - targetFieldLabel: targetColumn?.columnLabel, + targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value, }; } else { - newMappings[index] = { ...newMappings[index], [field]: value }; + newMappings[index] = { + ...newMappings[index], + [field]: value, + }; } setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); }; - const handleSave = () => { + // 즉시 반영 핸들러들 + const handleDisplayNameChange = (newDisplayName: string) => { + setDisplayName(newDisplayName); + updateNode(nodeId, { displayName: newDisplayName }); + }; + + const handleFieldMappingsChange = (newMappings: any[]) => { + setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); + }; + + const handleBatchSizeChange = (newBatchSize: string) => { + setBatchSize(newBatchSize); + updateNode(nodeId, { + options: { + batchSize: newBatchSize ? parseInt(newBatchSize) : undefined, + ignoreErrors, + ignoreDuplicates, + }, + }); + }; + + const handleIgnoreErrorsChange = (checked: boolean) => { + setIgnoreErrors(checked); + updateNode(nodeId, { + options: { + batchSize: batchSize ? parseInt(batchSize) : undefined, + ignoreErrors: checked, + ignoreDuplicates, + }, + }); + }; + + const handleIgnoreDuplicatesChange = (checked: boolean) => { + setIgnoreDuplicates(checked); updateNode(nodeId, { - displayName, - targetTable, - fieldMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, - ignoreDuplicates, + ignoreDuplicates: checked, }, }); }; @@ -552,7 +586,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP return ( -
+
{/* 🔥 타겟 타입 선택 */}
@@ -1219,7 +1253,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP id="batchSize" type="number" value={batchSize} - onChange={(e) => setBatchSize(e.target.value)} + onChange={(e) => handleBatchSizeChange(e.target.value)} className="mt-1" placeholder="한 번에 처리할 레코드 수" /> @@ -1229,7 +1263,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP setIgnoreDuplicates(checked as boolean)} + onCheckedChange={(checked) => handleIgnoreDuplicatesChange(checked as boolean)} />
{/* 저장 버튼 */} -
- -
{/* 안내 */}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/LogProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/LogProperties.tsx index 08c0d9bd..e8757d99 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/LogProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/LogProperties.tsx @@ -47,7 +47,7 @@ export function LogProperties({ nodeId, data }: LogPropertiesProps) { const LevelIcon = selectedLevel?.icon || Info; return ( -
+
로그 diff --git a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx index bf88336c..167f7c2a 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx @@ -263,7 +263,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope return ( -
+
{/* 기본 정보 */}

기본 정보

@@ -619,11 +619,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
{/* 저장 버튼 */} -
- -
{/* 안내 */}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/RestAPISourceProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/RestAPISourceProperties.tsx index 6f8e18d2..43c21255 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/RestAPISourceProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/RestAPISourceProperties.tsx @@ -124,7 +124,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie }; return ( -
+
REST API 소스 diff --git a/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx index b44e5128..72c3269d 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx @@ -5,13 +5,14 @@ */ import { useEffect, useState } from "react"; -import { Check, ChevronsUpDown } from "lucide-react"; +import { Check, ChevronsUpDown, Table, FileText } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; @@ -34,6 +35,11 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro const [displayName, setDisplayName] = useState(data.displayName || data.tableName); const [tableName, setTableName] = useState(data.tableName); + + // 🆕 데이터 소스 타입 (기본값: context-data) + const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">( + (data as any).dataSourceType || "context-data" + ); // 테이블 선택 관련 상태 const [tables, setTables] = useState([]); @@ -44,7 +50,8 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro useEffect(() => { setDisplayName(data.displayName || data.tableName); setTableName(data.tableName); - }, [data.displayName, data.tableName]); + setDataSourceType((data as any).dataSourceType || "context-data"); + }, [data.displayName, data.tableName, (data as any).dataSourceType]); // 테이블 목록 로딩 useEffect(() => { @@ -145,12 +152,22 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro }); }; + /** + * 🆕 데이터 소스 타입 변경 핸들러 + */ + const handleDataSourceTypeChange = (newType: "context-data" | "table-all") => { + setDataSourceType(newType); + updateNode(nodeId, { + dataSourceType: newType, + }); + console.log(`✅ 데이터 소스 타입 변경: ${newType}`); + }; + // 현재 선택된 테이블의 라벨 찾기 const selectedTableLabel = tables.find((t) => t.tableName === tableName)?.label || tableName; return ( - -
+
{/* 기본 정보 */}

기본 정보

@@ -237,15 +254,77 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
+ {/* 🆕 데이터 소스 설정 */} +
+

데이터 소스 설정

+ +
+
+ + + + {/* 설명 텍스트 */} +
+ {dataSourceType === "context-data" ? ( + <> +

💡 컨텍스트 데이터 모드

+

버튼 실행 시 전달된 데이터(폼 데이터, 테이블 선택 항목 등)를 사용합니다.

+

• 폼 데이터: 1개 레코드

+

• 테이블 선택: N개 레코드

+ + ) : ( + <> +

📊 테이블 전체 데이터 모드

+

선택한 테이블의 **모든 행**을 직접 조회합니다.

+

⚠️ 대량 데이터 시 성능 주의

+ + )} +
+
+
+
+ {/* 필드 정보 */}
-

출력 필드

+

+ 출력 필드 {data.fields && data.fields.length > 0 && `(${data.fields.length}개)`} +

{data.fields && data.fields.length > 0 ? ( -
+
{data.fields.map((field) => ( -
- {field.name} - {field.type} +
+ + {field.name} + + {field.type}
))}
@@ -254,9 +333,6 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro )}
- {/* 안내 */} -
✅ 변경 사항이 즉시 노드에 반영됩니다.
-
- +
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index 05c40fa4..2e70e28a 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -378,31 +378,22 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP }; const handleAddMapping = () => { - setFieldMappings([ + const newMappings = [ ...fieldMappings, { sourceField: null, targetField: "", staticValue: undefined, }, - ]); + ]; + setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); }; const handleRemoveMapping = (index: number) => { const newMappings = fieldMappings.filter((_, i) => i !== index); setFieldMappings(newMappings); - - // 즉시 반영 - updateNode(nodeId, { - displayName, - targetTable, - fieldMappings: newMappings, - whereConditions, - options: { - batchSize: batchSize ? parseInt(batchSize) : undefined, - ignoreErrors, - }, - }); + updateNode(nodeId, { fieldMappings: newMappings }); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -428,6 +419,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP } setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); }; // 🔥 타겟 타입 변경 핸들러 @@ -459,31 +451,22 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP }; const handleAddCondition = () => { - setWhereConditions([ + const newConditions = [ ...whereConditions, { field: "", operator: "EQUALS", staticValue: "", }, - ]); + ]; + setWhereConditions(newConditions); + updateNode(nodeId, { whereConditions: newConditions }); }; const handleRemoveCondition = (index: number) => { const newConditions = whereConditions.filter((_, i) => i !== index); setWhereConditions(newConditions); - - // 즉시 반영 - updateNode(nodeId, { - displayName, - targetTable, - fieldMappings, - whereConditions: newConditions, - options: { - batchSize: batchSize ? parseInt(batchSize) : undefined, - ignoreErrors, - }, - }); + updateNode(nodeId, { whereConditions: newConditions }); }; const handleConditionChange = (index: number, field: string, value: any) => { @@ -509,17 +492,41 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP } setWhereConditions(newConditions); + updateNode(nodeId, { whereConditions: newConditions }); }; - const handleSave = () => { + // 즉시 반영 핸들러들 + const handleDisplayNameChange = (newDisplayName: string) => { + setDisplayName(newDisplayName); + updateNode(nodeId, { displayName: newDisplayName }); + }; + + const handleFieldMappingsChange = (newMappings: any[]) => { + setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); + }; + + const handleWhereConditionsChange = (newConditions: any[]) => { + setWhereConditions(newConditions); + updateNode(nodeId, { whereConditions: newConditions }); + }; + + const handleBatchSizeChange = (newBatchSize: string) => { + setBatchSize(newBatchSize); + updateNode(nodeId, { + options: { + batchSize: newBatchSize ? parseInt(newBatchSize) : undefined, + ignoreErrors, + }, + }); + }; + + const handleIgnoreErrorsChange = (checked: boolean) => { + setIgnoreErrors(checked); updateNode(nodeId, { - displayName, - targetTable, - fieldMappings, - whereConditions, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, - ignoreErrors, + ignoreErrors: checked, }, }); }; @@ -528,7 +535,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP return ( -
+
{/* 기본 정보 */}

기본 정보

@@ -1273,7 +1280,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP id="batchSize" type="number" value={batchSize} - onChange={(e) => setBatchSize(e.target.value)} + onChange={(e) => handleBatchSizeChange(e.target.value)} className="mt-1" placeholder="예: 100" /> @@ -1283,7 +1290,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP setIgnoreErrors(checked as boolean)} + onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)} />
- {/* 적용 버튼 */} -
- -

✅ 변경 사항이 즉시 노드에 반영됩니다.

-
); diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx index 3e27d910..ee244063 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx @@ -380,6 +380,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP setConflictKeys(newConflictKeys); setConflictKeyLabels(newConflictKeyLabels); + updateNode(nodeId, { + conflictKeys: newConflictKeys, + conflictKeyLabels: newConflictKeyLabels, + }); } }; @@ -389,48 +393,29 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP setConflictKeys(newKeys); setConflictKeyLabels(newLabels); - - // 즉시 반영 updateNode(nodeId, { - displayName, - targetTable, conflictKeys: newKeys, conflictKeyLabels: newLabels, - fieldMappings, - options: { - batchSize: batchSize ? parseInt(batchSize) : undefined, - updateOnConflict, - }, }); }; const handleAddMapping = () => { - setFieldMappings([ + const newMappings = [ ...fieldMappings, { sourceField: null, targetField: "", staticValue: undefined, }, - ]); + ]; + setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); }; const handleRemoveMapping = (index: number) => { const newMappings = fieldMappings.filter((_, i) => i !== index); setFieldMappings(newMappings); - - // 즉시 반영 - updateNode(nodeId, { - displayName, - targetTable, - conflictKeys, - conflictKeyLabels, - fieldMappings: newMappings, - options: { - batchSize: batchSize ? parseInt(batchSize) : undefined, - updateOnConflict, - }, - }); + updateNode(nodeId, { fieldMappings: newMappings }); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -456,18 +441,41 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP } setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); }; - const handleSave = () => { + // 즉시 반영 핸들러들 + const handleDisplayNameChange = (newDisplayName: string) => { + setDisplayName(newDisplayName); + updateNode(nodeId, { displayName: newDisplayName }); + }; + + const handleConflictKeysChange = (newKeys: string[]) => { + setConflictKeys(newKeys); + updateNode(nodeId, { conflictKeys: newKeys }); + }; + + const handleFieldMappingsChange = (newMappings: any[]) => { + setFieldMappings(newMappings); + updateNode(nodeId, { fieldMappings: newMappings }); + }; + + const handleBatchSizeChange = (newBatchSize: string) => { + setBatchSize(newBatchSize); + updateNode(nodeId, { + options: { + batchSize: newBatchSize ? parseInt(newBatchSize) : undefined, + updateOnConflict, + }, + }); + }; + + const handleUpdateOnConflictChange = (checked: boolean) => { + setUpdateOnConflict(checked); updateNode(nodeId, { - displayName, - targetTable, - conflictKeys, - conflictKeyLabels, - fieldMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, - updateOnConflict, + updateOnConflict: checked, }, }); }; @@ -476,7 +484,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP return ( -
+
{/* 기본 정보 */}

기본 정보

@@ -1145,13 +1153,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
- {/* 적용 버튼 */} -
- -

✅ 변경 사항이 즉시 노드에 반영됩니다.

-
); diff --git a/frontend/components/screen/OptimizedButtonComponent.tsx b/frontend/components/screen/OptimizedButtonComponent.tsx index a030109e..3ff1c4e4 100644 --- a/frontend/components/screen/OptimizedButtonComponent.tsx +++ b/frontend/components/screen/OptimizedButtonComponent.tsx @@ -27,6 +27,18 @@ interface OptimizedButtonProps { selectedRowsData?: any[]; flowSelectedData?: any[]; flowSelectedStepId?: number | null; + + // 🆕 테이블 전체 데이터 (table-all 모드용) + tableAllData?: any[]; + + // 🆕 플로우 스텝 전체 데이터 (flow-step-all 모드용) + flowStepAllData?: any[]; + + // 🆕 테이블 전체 데이터 로드 콜백 (필요 시 부모에서 제공) + onRequestTableAllData?: () => Promise; + + // 🆕 플로우 스텝 전체 데이터 로드 콜백 (필요 시 부모에서 제공) + onRequestFlowStepAllData?: (stepId: number) => Promise; } /** @@ -50,6 +62,10 @@ export const OptimizedButtonComponent: React.FC = ({ selectedRowsData = [], flowSelectedData = [], flowSelectedStepId = null, + tableAllData = [], + flowStepAllData = [], + onRequestTableAllData, + onRequestFlowStepAllData, }) => { // 🔥 상태 관리 const [isExecuting, setIsExecuting] = useState(false); @@ -161,6 +177,47 @@ export const OptimizedButtonComponent: React.FC = ({ // 🆕 노드 플로우 방식 실행 if (config.dataflowConfig.controlMode === "flow" && config.dataflowConfig.flowConfig) { console.log("🔄 노드 플로우 방식 실행:", config.dataflowConfig.flowConfig); + console.log("📊 전달될 데이터 확인:", { + controlDataSource: config.dataflowConfig.controlDataSource, + formDataKeys: Object.keys(formData), + selectedRowsDataLength: selectedRowsData.length, + flowSelectedDataLength: flowSelectedData.length, + flowSelectedStepId, + }); + + // 🆕 데이터 소스에 따라 추가 데이터 로드 + let preparedTableAllData = tableAllData; + let preparedFlowStepAllData = flowStepAllData; + + const dataSource = config.dataflowConfig.controlDataSource; + + // table-all 모드일 때 데이터 로드 + if (dataSource === "table-all" || dataSource === "all-sources") { + if (tableAllData.length === 0 && onRequestTableAllData) { + console.log("📊 테이블 전체 데이터 로드 중..."); + try { + preparedTableAllData = await onRequestTableAllData(); + console.log(`✅ 테이블 전체 데이터 ${preparedTableAllData.length}건 로드 완료`); + } catch (error) { + console.error("❌ 테이블 전체 데이터 로드 실패:", error); + toast.error("테이블 전체 데이터를 불러오지 못했습니다"); + } + } + } + + // flow-step-all 모드일 때 데이터 로드 + if ((dataSource === "flow-step-all" || dataSource === "all-sources") && flowSelectedStepId) { + if (flowStepAllData.length === 0 && onRequestFlowStepAllData) { + console.log(`📊 플로우 스텝 ${flowSelectedStepId} 전체 데이터 로드 중...`); + try { + preparedFlowStepAllData = await onRequestFlowStepAllData(flowSelectedStepId); + console.log(`✅ 플로우 스텝 전체 데이터 ${preparedFlowStepAllData.length}건 로드 완료`); + } catch (error) { + console.error("❌ 플로우 스텝 전체 데이터 로드 실패:", error); + toast.error("플로우 스텝 전체 데이터를 불러오지 못했습니다"); + } + } + } const flowResult = await executeButtonWithFlow( config.dataflowConfig.flowConfig, @@ -172,7 +229,12 @@ export const OptimizedButtonComponent: React.FC = ({ formData, selectedRows: selectedRows || [], selectedRowsData: selectedRowsData || [], + flowSelectedData: flowSelectedData || [], + flowStepId: flowSelectedStepId || undefined, controlDataSource: config.dataflowConfig.controlDataSource, + // 🆕 확장된 데이터 소스 + tableAllData: preparedTableAllData, + flowStepAllData: preparedFlowStepAllData, }, // 원래 액션 (timing이 before나 after일 때 실행) async () => { diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 1c841828..e08cae82 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -55,6 +55,7 @@ import { interface RealtimePreviewProps { component: ComponentData; isSelected?: boolean; + isDesignMode?: boolean; onClick?: (e?: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; @@ -104,7 +105,7 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => { }; // 동적 웹 타입 위젯 렌더링 컴포넌트 -const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => { +const WidgetRenderer: React.FC<{ component: ComponentData; isDesignMode?: boolean }> = ({ component, isDesignMode = false }) => { // 위젯 컴포넌트가 아닌 경우 빈 div 반환 if (!isWidgetComponent(component)) { return
위젯이 아닙니다
; @@ -151,6 +152,8 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) = component: widget, value: undefined, // 미리보기이므로 값은 없음 readonly: readonly, + isDesignMode, + isInteractive: !isDesignMode, }} config={widget.webTypeConfig} /> @@ -215,6 +218,7 @@ const getWidgetIcon = (widgetType: WebType | undefined) => { export const RealtimePreviewDynamic: React.FC = ({ component, isSelected = false, + isDesignMode = false, onClick, onDragStart, onDragEnd, @@ -515,7 +519,7 @@ export const RealtimePreviewDynamic: React.FC = ({ {/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */} {type === "widget" && !isFileComponent(component) && (
- +
)} diff --git a/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx index 6659d469..afa181f8 100644 --- a/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx @@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Check, ChevronsUpDown, Search, Info, Settings, FileText, Table, Layers } from "lucide-react"; +import { Check, ChevronsUpDown, Search, Info, Settings, FileText, Table, Layers, Workflow } from "lucide-react"; import { cn } from "@/lib/utils"; import { ComponentData, ButtonDataflowConfig } from "@/types/screen"; import { apiClient } from "@/lib/api/client"; @@ -254,13 +254,31 @@ export const ButtonDataflowConfigPanel: React.FC
- 폼 데이터 기반 + 폼 데이터
- 테이블 선택 기반 + 테이블 선택 항목 + + + +
+
+ 테이블 전체 데이터 🆕 + + + +
+ + 플로우 선택 항목 +
+
+ +
+ + 플로우 스텝 전체 데이터 🆕
@@ -269,13 +287,22 @@ export const ButtonDataflowConfigPanel: React.FC 폼 + 테이블 선택 + +
+ + 모든 소스 결합 🆕 +
+

{dataflowConfig.controlDataSource === "form" && "현재 폼의 입력값으로 조건을 체크합니다"} - {dataflowConfig.controlDataSource === "table-selection" && - "테이블에서 선택된 항목의 데이터로 조건을 체크합니다"} + {dataflowConfig.controlDataSource === "table-selection" && "테이블에서 선택된 항목의 데이터로 조건을 체크합니다"} + {dataflowConfig.controlDataSource === "table-all" && "테이블의 모든 데이터(페이징 무관)로 조건을 체크합니다"} + {dataflowConfig.controlDataSource === "flow-selection" && "플로우에서 선택된 항목의 데이터로 조건을 체크합니다"} + {dataflowConfig.controlDataSource === "flow-step-all" && "현재 선택된 플로우 스텝의 모든 데이터로 조건을 체크합니다"} {dataflowConfig.controlDataSource === "both" && "폼 데이터와 선택된 항목 데이터를 모두 사용합니다"} + {dataflowConfig.controlDataSource === "all-sources" && "폼, 테이블 전체, 플로우 등 모든 소스의 데이터를 결합하여 사용합니다"} {!dataflowConfig.controlDataSource && "폼 데이터를 기본으로 사용합니다"}

diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 8a08f30e..c7a846c1 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -4,13 +4,12 @@ import React, { useEffect, useState } from "react"; import { FlowComponent } from "@/types/screen-management"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { AlertCircle, Loader2, ChevronDown, ChevronUp, History } from "lucide-react"; -import { getFlowById, getAllStepCounts, getStepDataList, moveBatchData, getFlowAuditLogs } from "@/lib/api/flow"; +import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react"; +import { getFlowById, getAllStepCounts, getStepDataList, getFlowAuditLogs } from "@/lib/api/flow"; import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "sonner"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent, @@ -55,8 +54,6 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR const [stepDataColumns, setStepDataColumns] = useState([]); const [stepDataLoading, setStepDataLoading] = useState(false); const [selectedRows, setSelectedRows] = useState>(new Set()); - const [movingData, setMovingData] = useState(false); - const [selectedNextStepId, setSelectedNextStepId] = useState(null); // 선택된 다음 단계 // 오딧 로그 상태 const [auditLogs, setAuditLogs] = useState([]); @@ -303,84 +300,6 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR onSelectedDataChange?.(selectedData, selectedStepId); }; - // 현재 단계에서 가능한 다음 단계들 찾기 - const getNextSteps = (currentStepId: number) => { - return connections - .filter((conn) => conn.fromStepId === currentStepId) - .map((conn) => steps.find((s) => s.id === conn.toStepId)) - .filter((step) => step !== undefined); - }; - - // 다음 단계로 이동 - const handleMoveToNext = async (targetStepId?: number) => { - if (!flowId || !selectedStepId || selectedRows.size === 0) return; - - // 다음 단계 결정 - let nextStepId = targetStepId || selectedNextStepId; - - if (!nextStepId) { - const nextSteps = getNextSteps(selectedStepId); - if (nextSteps.length === 0) { - toast.error("다음 단계가 없습니다"); - return; - } - if (nextSteps.length === 1) { - nextStepId = nextSteps[0].id; - } else { - toast.error("다음 단계를 선택해주세요"); - return; - } - } - - const selectedData = Array.from(selectedRows).map((index) => stepData[index]); - - try { - setMovingData(true); - - // Primary Key 컬럼 추출 (첫 번째 컬럼 가정) - const primaryKeyColumn = stepDataColumns[0]; - const dataIds = selectedData.map((data) => String(data[primaryKeyColumn])); - - // 배치 이동 API 호출 - const response = await moveBatchData({ - flowId, - fromStepId: selectedStepId, - toStepId: nextStepId, - dataIds, - }); - - if (!response.success) { - throw new Error(response.message || "데이터 이동에 실패했습니다"); - } - - const nextStepName = steps.find((s) => s.id === nextStepId)?.stepName; - toast.success(`${selectedRows.size}건의 데이터를 "${nextStepName}"(으)로 이동했습니다`); - - // 선택 초기화 - setSelectedNextStepId(null); - setSelectedRows(new Set()); - // 선택 초기화 전달 - onSelectedDataChange?.([], selectedStepId); - - // 데이터 새로고침 - await handleStepClick(selectedStepId, steps.find((s) => s.id === selectedStepId)?.stepName || ""); - - // 건수 새로고침 - const countsResponse = await getAllStepCounts(flowId); - if (countsResponse.success && countsResponse.data) { - const countsMap: Record = {}; - countsResponse.data.forEach((item: any) => { - countsMap[item.stepId] = item.count; - }); - setStepCounts(countsMap); - } - } catch (err: any) { - console.error("Failed to move data:", err); - toast.error(err.message || "데이터 이동 중 오류가 발생했습니다"); - } finally { - setMovingData(false); - } - }; // 오딧 로그 로드 const loadAuditLogs = async () => { @@ -716,93 +635,18 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR {selectedStepId !== null && (
{/* 헤더 */} -
+

{steps.find((s) => s.id === selectedStepId)?.stepName}

-

총 {stepData.length}건의 데이터

+

+ 총 {stepData.length}건의 데이터 + {selectedRows.size > 0 && ( + ({selectedRows.size}건 선택됨) + )} +

- {allowDataMove && - selectedRows.size > 0 && - (() => { - const nextSteps = getNextSteps(selectedStepId); - return nextSteps.length > 1 ? ( - // 다음 단계가 여러 개인 경우: 선택 UI 표시 -
- - -
- ) : ( - // 다음 단계가 하나인 경우: 바로 이동 버튼만 표시 - - ); - })()}
{/* 데이터 테이블 */} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 9f107453..b2c84506 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -204,21 +204,6 @@ export const DynamicComponentRenderer: React.FC = const fieldName = (component as any).columnName || component.id; const currentValue = formData?.[fieldName] || ""; - console.log("🔍 DynamicComponentRenderer - 새 컴포넌트 시스템:", { - componentType, - componentId: component.id, - columnName: (component as any).columnName, - fieldName, - currentValue, - hasFormData: !!formData, - formDataKeys: formData ? Object.keys(formData) : [], - autoGeneration: component.autoGeneration, - hidden: component.hidden, - isInteractive, - isPreview, // 반응형 모드 플래그 - isDesignMode: props.isDesignMode, // 디자인 모드 플래그 - }); - // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { // React 이벤트 객체인 경우 값 추출 @@ -226,24 +211,13 @@ export const DynamicComponentRenderer: React.FC = if (value && typeof value === "object" && value.nativeEvent && value.target) { // SyntheticEvent인 경우 target.value 추출 actualValue = value.target.value; - console.log("⚠️ DynamicComponentRenderer: 이벤트 객체 감지, value 추출:", actualValue); } - console.log("🔄 DynamicComponentRenderer handleChange 호출:", { - componentType, - fieldName, - originalValue: value, - actualValue, - valueType: typeof actualValue, - isArray: Array.isArray(actualValue), - }); - if (onFormDataChange) { // RepeaterInput 같은 복합 컴포넌트는 전체 데이터를 전달 // 단순 input 컴포넌트는 (fieldName, value) 형태로 전달받음 if (componentType === "repeater-field-group" || componentType === "repeater") { // fieldName과 함께 전달 - console.log("💾 RepeaterInput 데이터 저장:", fieldName, actualValue); onFormDataChange(fieldName, actualValue); } else { // 이미 fieldName이 포함된 경우는 그대로 전달 @@ -256,18 +230,8 @@ export const DynamicComponentRenderer: React.FC = // component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리) const { height: _height, ...styleWithoutHeight } = component.style || {}; - // 숨김 값 추출 (디버깅) + // 숨김 값 추출 const hiddenValue = component.hidden || component.componentConfig?.hidden; - if (hiddenValue) { - console.log("🔍 DynamicComponentRenderer hidden 체크:", { - componentId: component.id, - componentType, - componentHidden: component.hidden, - componentConfigHidden: component.componentConfig?.hidden, - finalHiddenValue: hiddenValue, - isDesignMode: props.isDesignMode, - }); - } const rendererProps = { component, @@ -322,26 +286,16 @@ export const DynamicComponentRenderer: React.FC = }; // 렌더러가 클래스인지 함수인지 확인 - console.log("🔍🔍 DynamicComponentRenderer - 렌더러 타입 확인:", { - componentType, - isFunction: typeof NewComponentRenderer === "function", - hasPrototype: !!NewComponentRenderer.prototype, - hasRenderMethod: !!NewComponentRenderer.prototype?.render, - rendererName: NewComponentRenderer.name, - }); - if ( typeof NewComponentRenderer === "function" && NewComponentRenderer.prototype && NewComponentRenderer.prototype.render ) { // 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속) - console.log("✅ 클래스 기반 렌더러로 렌더링:", componentType); const rendererInstance = new NewComponentRenderer(rendererProps); return rendererInstance.render(); } else { // 함수형 컴포넌트 - console.log("✅ 함수형 컴포넌트로 렌더링:", componentType); return ; } } diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 681eb590..d483f9af 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -73,19 +73,6 @@ export const ButtonPrimaryComponent: React.FC = ({ flowSelectedStepId, ...props }) => { - console.log("🔵 ButtonPrimaryComponent 렌더링, 받은 props:", { - componentId: component.id, - hasSelectedRowsData: !!selectedRowsData, - selectedRowsDataLength: selectedRowsData?.length, - selectedRowsData, - hasFlowSelectedData: !!flowSelectedData, - flowSelectedDataLength: flowSelectedData?.length, - flowSelectedData, - flowSelectedStepId, - tableName, - screenId, - }); - // 🆕 플로우 단계별 표시 제어 const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId); @@ -101,7 +88,6 @@ export const ButtonPrimaryComponent: React.FC = ({ if (currentStep === null) { // 🔧 화이트리스트 모드일 때는 단계 미선택 시 숨김 if (flowConfig.mode === "whitelist") { - console.log("🔍 [ButtonPrimary] 화이트리스트 모드 + 단계 미선택 → 숨김"); return false; } // 블랙리스트나 all 모드는 표시 @@ -119,18 +105,6 @@ export const ButtonPrimaryComponent: React.FC = ({ result = true; } - // 항상 로그 출력 - console.log("🔍 [ButtonPrimary] 표시 체크:", { - buttonId: component.id, - buttonLabel: component.label, - flowComponentId: flowConfig.targetFlowComponentId, - currentStep, - mode, - visibleSteps, - hiddenSteps, - result: result ? "표시 ✅" : "숨김 ❌", - }); - return result; }, [flowConfig, currentStep, component.id, component.label]); @@ -149,7 +123,6 @@ export const ButtonPrimaryComponent: React.FC = ({ useEffect(() => { return () => { if (currentLoadingToastRef.current !== undefined) { - console.log("🧹 컴포넌트 언마운트 시 토스트 정리"); toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } @@ -240,21 +213,6 @@ export const ButtonPrimaryComponent: React.FC = ({ }; } - // 디버그 로그 (필요시 주석 해제) - // console.log("🔧 버튼 컴포넌트 설정:", { - // originalConfig: componentConfig, - // processedConfig, - // actionConfig: processedConfig.action, - // webTypeConfig: component.webTypeConfig, - // enableDataflowControl: component.webTypeConfig?.enableDataflowControl, - // dataflowConfig: component.webTypeConfig?.dataflowConfig, - // screenId, - // tableName, - // onRefresh, - // onClose, - // selectedRows, - // selectedRowsData, - // }); // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 @@ -278,12 +236,9 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실제 액션 실행 함수 const executeAction = async (actionConfig: any, context: ButtonActionContext) => { - // console.log("🚀 executeAction 시작:", { actionConfig, context }); - try { // 기존 토스트가 있다면 먼저 제거 if (currentLoadingToastRef.current !== undefined) { - console.log("📱 기존 토스트 제거"); toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } @@ -294,7 +249,6 @@ export const ButtonPrimaryComponent: React.FC = ({ // UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시 const silentActions = ["edit", "modal", "navigate"]; if (!silentActions.includes(actionConfig.type)) { - console.log("📱 로딩 토스트 표시 시작"); currentLoadingToastRef.current = toast.loading( actionConfig.type === "save" ? "저장 중..." @@ -307,23 +261,12 @@ export const ButtonPrimaryComponent: React.FC = ({ duration: Infinity, // 명시적으로 무한대로 설정 }, ); - console.log("📱 로딩 토스트 ID:", currentLoadingToastRef.current); - } else { - console.log("🔕 UI 전환 액션은 로딩 토스트 표시 안함:", actionConfig.type); } - console.log("⚡ ButtonActionExecutor.executeAction 호출 시작"); - console.log("🔍 actionConfig 확인:", { - type: actionConfig.type, - successMessage: actionConfig.successMessage, - errorMessage: actionConfig.errorMessage, - }); const success = await ButtonActionExecutor.executeAction(actionConfig, context); - console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success); // 로딩 토스트 제거 (있는 경우에만) if (currentLoadingToastRef.current !== undefined) { - console.log("📱 로딩 토스트 제거 시도, ID:", currentLoadingToastRef.current); toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } @@ -333,11 +276,8 @@ export const ButtonPrimaryComponent: React.FC = ({ // UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리 const silentActions = ["edit", "modal", "navigate"]; if (silentActions.includes(actionConfig.type)) { - console.log("🔕 UI 전환 액션 실패지만 에러 토스트 표시 안함:", actionConfig.type); return; } - - console.log("❌ 액션 실패, 오류 토스트 표시"); // 기본 에러 메시지 결정 const defaultErrorMessage = actionConfig.type === "save" @@ -357,13 +297,6 @@ export const ButtonPrimaryComponent: React.FC = ({ const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage; - console.log("🔍 에러 메시지 결정:", { - actionType: actionConfig.type, - customMessage: actionConfig.errorMessage, - useCustom: useCustomMessage, - finalMessage: errorMessage - }); - toast.error(errorMessage); return; } @@ -390,19 +323,13 @@ export const ButtonPrimaryComponent: React.FC = ({ const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage; - console.log("🎉 성공 토스트 표시:", successMessage); toast.success(successMessage); - } else { - console.log("🔕 UI 전환 액션은 조용히 처리 (토스트 없음):", actionConfig.type); } - console.log("✅ 버튼 액션 실행 성공:", actionConfig.type); - // 저장/수정 성공 시 자동 처리 if (actionConfig.type === "save" || actionConfig.type === "edit") { if (typeof window !== "undefined") { // 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에) - console.log("🔄 저장/수정 후 테이블 새로고침 이벤트 발송"); window.dispatchEvent(new CustomEvent("refreshTable")); // 2. 모달 닫기 (약간의 딜레이) @@ -411,22 +338,17 @@ export const ButtonPrimaryComponent: React.FC = ({ const isInEditModal = (props as any).isInModal; if (isInEditModal) { - console.log("🚪 EditModal 닫기 이벤트 발송"); window.dispatchEvent(new CustomEvent("closeEditModal")); } // ScreenModal은 항상 닫기 - console.log("🚪 ScreenModal 닫기 이벤트 발송"); window.dispatchEvent(new CustomEvent("closeSaveModal")); }, 100); } } } catch (error) { - console.log("❌ executeAction catch 블록 진입:", error); - // 로딩 토스트 제거 if (currentLoadingToastRef.current !== undefined) { - console.log("📱 오류 시 로딩 토스트 제거, ID:", currentLoadingToastRef.current); toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } @@ -441,12 +363,6 @@ export const ButtonPrimaryComponent: React.FC = ({ // 이벤트 핸들러 const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); - console.log("🖱️ 버튼 클릭 이벤트 발생", { - isDesignMode, - isInteractive, - hasAction: !!processedConfig.action, - processedConfig, - }); // 디자인 모드에서는 기본 onClick만 실행 if (isDesignMode) { @@ -454,29 +370,13 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } - console.log("🔍 조건 체크:", { - isInteractive, - hasProcessedConfig: !!processedConfig, - hasAction: !!processedConfig.action, - actionType: processedConfig.action?.type, - }); - // 인터랙티브 모드에서 액션 실행 if (isInteractive && processedConfig.action) { - console.log("✅ 액션 실행 조건 통과", { - actionType: processedConfig.action.type, - requiresConfirmation: confirmationRequiredActions.includes(processedConfig.action.type), - }); - // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 const hasDataToDelete = (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); if (processedConfig.action.type === "delete" && !hasDataToDelete) { - console.log("⚠️ 삭제할 데이터가 선택되지 않았습니다.", { - hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0), - hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), - }); toast.warning("삭제할 항목을 먼저 선택해주세요."); return; } @@ -498,22 +398,8 @@ export const ButtonPrimaryComponent: React.FC = ({ flowSelectedStepId, }; - console.log("🔍 버튼 액션 실행 전 context 확인:", { - hasSelectedRowsData: !!selectedRowsData, - selectedRowsDataLength: selectedRowsData?.length, - selectedRowsData, - hasFlowSelectedData: !!flowSelectedData, - flowSelectedDataLength: flowSelectedData?.length, - flowSelectedData, - flowSelectedStepId, - tableName, - screenId, - formData, - }); - // 확인이 필요한 액션인지 확인 if (confirmationRequiredActions.includes(processedConfig.action.type)) { - console.log("📋 확인 다이얼로그 표시 중..."); // 확인 다이얼로그 표시 setPendingAction({ type: processedConfig.action.type, @@ -522,16 +408,10 @@ export const ButtonPrimaryComponent: React.FC = ({ }); setShowConfirmDialog(true); } else { - console.log("🚀 액션 바로 실행 중..."); // 확인이 필요하지 않은 액션은 바로 실행 await executeAction(processedConfig.action, context); } } else { - console.log("⚠️ 액션 실행 조건 불만족:", { - isInteractive, - hasAction: !!processedConfig.action, - 이유: !isInteractive ? "인터랙티브 모드 아님" : "액션 없음", - }); // 액션이 설정되지 않은 경우 기본 onClick 실행 onClick?.(); } diff --git a/frontend/lib/services/optimizedButtonDataflowService.ts b/frontend/lib/services/optimizedButtonDataflowService.ts index fe22b43d..d48df1e0 100644 --- a/frontend/lib/services/optimizedButtonDataflowService.ts +++ b/frontend/lib/services/optimizedButtonDataflowService.ts @@ -540,13 +540,7 @@ export class OptimizedButtonDataflowService { }); if (!isValid) { - const sourceLabel = - context.controlDataSource === "form" - ? "폼" - : context.controlDataSource === "table-selection" - ? "선택된 항목" - : "데이터"; - + const sourceLabel = getDataSourceLabel(context.controlDataSource); const actualValueMsg = fieldValue !== undefined ? ` (실제값: ${fieldValue})` : " (값 없음)"; return { @@ -755,5 +749,29 @@ export class OptimizedButtonDataflowService { } } +/** + * 데이터 소스 타입에 따른 한글 레이블 반환 + */ +function getDataSourceLabel(dataSource: string | undefined): string { + switch (dataSource) { + case "form": + return "폼"; + case "table-selection": + return "테이블 선택 항목"; + case "table-all": + return "테이블 전체"; + case "flow-selection": + return "플로우 선택 항목"; + case "flow-step-all": + return "플로우 스텝 전체"; + case "both": + return "폼 + 테이블 선택"; + case "all-sources": + return "모든 소스"; + default: + return "데이터"; + } +} + // 🔥 전역 접근을 위한 싱글톤 서비스 export const optimizedButtonDataflowService = OptimizedButtonDataflowService; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index bbd8bbed..22486ac9 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -778,7 +778,7 @@ export class ButtonActionExecutor { let controlDataSource = config.dataflowConfig.controlDataSource; if (!controlDataSource) { - // 설정이 없으면 자동 판단 + // 설정이 없으면 자동 판단 (우선순위 순서대로) if (context.flowSelectedData && context.flowSelectedData.length > 0) { controlDataSource = "flow-selection"; console.log("🔄 자동 판단: flow-selection 모드 사용"); @@ -794,6 +794,13 @@ export class ButtonActionExecutor { } } + console.log("📊 데이터 소스 모드:", { + controlDataSource, + hasFormData: !!(context.formData && Object.keys(context.formData).length > 0), + hasTableSelection: !!(context.selectedRowsData && context.selectedRowsData.length > 0), + hasFlowSelection: !!(context.flowSelectedData && context.flowSelectedData.length > 0), + }); + const extendedContext: ExtendedControlContext = { formData: context.formData || {}, selectedRows: context.selectedRows || [], @@ -824,31 +831,92 @@ export class ButtonActionExecutor { // 노드 플로우 실행 API 호출 (API 클라이언트 사용) const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); - // 데이터 소스 준비: 플로우 선택, 테이블 선택, 또는 폼 데이터 + // 데이터 소스 준비: controlDataSource 설정 기반 let sourceData: any = null; - let dataSourceType: string = "none"; + let dataSourceType: string = controlDataSource || "none"; - if (context.flowSelectedData && context.flowSelectedData.length > 0) { - // 플로우에서 선택된 데이터 사용 - sourceData = context.flowSelectedData; - dataSourceType = "flow-selection"; - console.log("🌊 플로우 선택 데이터 사용:", { - stepId: context.flowSelectedStepId, - dataCount: sourceData.length, - sourceData, - }); - } else if (context.selectedRowsData && context.selectedRowsData.length > 0) { - // 테이블에서 선택된 행 데이터 사용 - sourceData = context.selectedRowsData; - dataSourceType = "table-selection"; - console.log("📊 테이블 선택 데이터 사용:", sourceData); - } else if (context.formData && Object.keys(context.formData).length > 0) { - // 폼 데이터 사용 (배열로 감싸서 일관성 유지) - sourceData = [context.formData]; - dataSourceType = "form"; - console.log("📝 폼 데이터 사용:", sourceData); + console.log("🔍 데이터 소스 결정:", { + controlDataSource, + hasFlowSelectedData: !!(context.flowSelectedData && context.flowSelectedData.length > 0), + hasSelectedRowsData: !!(context.selectedRowsData && context.selectedRowsData.length > 0), + hasFormData: !!(context.formData && Object.keys(context.formData).length > 0), + }); + + // controlDataSource 설정에 따라 데이터 선택 + switch (controlDataSource) { + case "flow-selection": + if (context.flowSelectedData && context.flowSelectedData.length > 0) { + sourceData = context.flowSelectedData; + console.log("🌊 플로우 선택 데이터 사용:", { + stepId: context.flowSelectedStepId, + dataCount: sourceData.length, + sourceData, + }); + } else { + console.warn("⚠️ flow-selection 모드이지만 선택된 플로우 데이터가 없습니다."); + } + break; + + case "table-selection": + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + sourceData = context.selectedRowsData; + console.log("📊 테이블 선택 데이터 사용:", { + dataCount: sourceData.length, + sourceData, + }); + } else { + console.warn("⚠️ table-selection 모드이지만 선택된 행이 없습니다."); + } + break; + + case "form": + if (context.formData && Object.keys(context.formData).length > 0) { + sourceData = [context.formData]; + console.log("📝 폼 데이터 사용:", sourceData); + } else { + console.warn("⚠️ form 모드이지만 폼 데이터가 없습니다."); + } + break; + + case "both": + // 폼 + 테이블 선택 + sourceData = []; + if (context.formData && Object.keys(context.formData).length > 0) { + sourceData.push(context.formData); + } + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + sourceData.push(...context.selectedRowsData); + } + console.log("🔀 폼 + 테이블 선택 데이터 사용:", { + dataCount: sourceData.length, + sourceData, + }); + break; + + default: + // 자동 판단 (설정이 없는 경우) + if (context.flowSelectedData && context.flowSelectedData.length > 0) { + sourceData = context.flowSelectedData; + dataSourceType = "flow-selection"; + console.log("🌊 [자동] 플로우 선택 데이터 사용"); + } else if (context.selectedRowsData && context.selectedRowsData.length > 0) { + sourceData = context.selectedRowsData; + dataSourceType = "table-selection"; + console.log("📊 [자동] 테이블 선택 데이터 사용"); + } else if (context.formData && Object.keys(context.formData).length > 0) { + sourceData = [context.formData]; + dataSourceType = "form"; + console.log("📝 [자동] 폼 데이터 사용"); + } + break; } + console.log("📦 최종 전달 데이터:", { + dataSourceType, + sourceDataCount: sourceData ? (Array.isArray(sourceData) ? sourceData.length : 1) : 0, + sourceData, + }); + const result = await executeNodeFlow(flowId, { dataSourceType, sourceData, @@ -857,10 +925,17 @@ export class ButtonActionExecutor { if (result.success) { console.log("✅ 노드 플로우 실행 완료:", result); - toast.success(config.successMessage || "플로우 실행이 완료되었습니다."); + toast.success("플로우 실행이 완료되었습니다."); - // 새로고침이 필요한 경우 + // 플로우 새로고침 (플로우 위젯용) + if (context.onFlowRefresh) { + console.log("🔄 플로우 새로고침 호출"); + context.onFlowRefresh(); + } + + // 테이블 새로고침 (일반 테이블용) if (context.onRefresh) { + console.log("🔄 테이블 새로고침 호출"); context.onRefresh(); } diff --git a/frontend/lib/utils/nodeFlowButtonExecutor.ts b/frontend/lib/utils/nodeFlowButtonExecutor.ts index 40b56875..68a9bb20 100644 --- a/frontend/lib/utils/nodeFlowButtonExecutor.ts +++ b/frontend/lib/utils/nodeFlowButtonExecutor.ts @@ -15,7 +15,18 @@ export interface ButtonExecutionContext { formData: Record; selectedRows?: any[]; selectedRowsData?: Record[]; - controlDataSource?: "form" | "table-selection" | "both"; + controlDataSource?: "form" | "table-selection" | "table-all" | "flow-selection" | "flow-step-all" | "both" | "all-sources"; + + // 🆕 테이블 전체 데이터 (table-all 모드용) + tableAllData?: Record[]; + + // 🆕 플로우 스텝 전체 데이터 (flow-step-all 모드용) + flowStepAllData?: Record[]; + flowStepId?: number; + + // 🆕 플로우 선택 데이터 (flow-selection 모드용) + flowSelectedData?: Record[]; + onRefresh?: () => void; onClose?: () => void; } @@ -141,15 +152,134 @@ export async function executeButtonWithFlow( * 컨텍스트 데이터 준비 */ function prepareContextData(context: ButtonExecutionContext): Record { - return { + // 🔥 controlDataSource 자동 감지 (명시적으로 설정되지 않은 경우) + let dataSource = context.controlDataSource; + + if (!dataSource) { + // 1. 플로우 선택 데이터가 있으면 flow-selection + if (context.flowSelectedData && context.flowSelectedData.length > 0) { + dataSource = "flow-selection"; + logger.info("🔄 자동 판단: flow-selection 모드 사용", { + flowSelectedDataLength: context.flowSelectedData.length, + }); + } + // 2. 플로우 스텝 전체 데이터가 있으면 flow-step-all + else if (context.flowStepAllData && context.flowStepAllData.length > 0) { + dataSource = "flow-step-all"; + logger.info("🔄 자동 판단: flow-step-all 모드 사용", { + flowStepAllDataLength: context.flowStepAllData.length, + }); + } + // 3. 테이블 선택 데이터가 있으면 table-selection + else if (context.selectedRowsData && context.selectedRowsData.length > 0) { + dataSource = "table-selection"; + logger.info("🔄 자동 판단: table-selection 모드 사용", { + selectedRowsDataLength: context.selectedRowsData.length, + }); + } + // 4. 테이블 전체 데이터가 있으면 table-all + else if (context.tableAllData && context.tableAllData.length > 0) { + dataSource = "table-all"; + logger.info("🔄 자동 판단: table-all 모드 사용", { + tableAllDataLength: context.tableAllData.length, + }); + } + // 5. 폼 데이터만 있으면 form + else { + dataSource = "form"; + logger.info("🔄 자동 판단: form 모드 사용"); + } + } + + const baseContext = { buttonId: context.buttonId, screenId: context.screenId, companyCode: context.companyCode, userId: context.userId, - formData: context.formData || {}, - selectedRowsData: context.selectedRowsData || [], - controlDataSource: context.controlDataSource || "form", + controlDataSource: dataSource, }; + + // 데이터 소스에 따라 데이터 준비 + + switch (dataSource) { + case "form": + return { + ...baseContext, + formData: context.formData || {}, + sourceData: [context.formData || {}], // 배열로 통일 + }; + + case "table-selection": + return { + ...baseContext, + formData: context.formData || {}, + selectedRowsData: context.selectedRowsData || [], + sourceData: context.selectedRowsData || [], + }; + + case "table-all": + return { + ...baseContext, + formData: context.formData || {}, + tableAllData: context.tableAllData || [], + sourceData: context.tableAllData || [], + }; + + case "flow-selection": + return { + ...baseContext, + formData: context.formData || {}, + flowSelectedData: context.flowSelectedData || [], + sourceData: context.flowSelectedData || [], + }; + + case "flow-step-all": + return { + ...baseContext, + formData: context.formData || {}, + flowStepAllData: context.flowStepAllData || [], + flowStepId: context.flowStepId, + sourceData: context.flowStepAllData || [], + }; + + case "both": + // 폼 + 테이블 선택 + return { + ...baseContext, + formData: context.formData || {}, + selectedRowsData: context.selectedRowsData || [], + sourceData: [ + context.formData || {}, + ...(context.selectedRowsData || []), + ], + }; + + case "all-sources": + // 모든 소스 결합 + return { + ...baseContext, + formData: context.formData || {}, + selectedRowsData: context.selectedRowsData || [], + tableAllData: context.tableAllData || [], + flowSelectedData: context.flowSelectedData || [], + flowStepAllData: context.flowStepAllData || [], + sourceData: [ + context.formData || {}, + ...(context.selectedRowsData || []), + ...(context.tableAllData || []), + ...(context.flowSelectedData || []), + ...(context.flowStepAllData || []), + ].filter(item => Object.keys(item).length > 0), // 빈 객체 제거 + }; + + default: + logger.warn(`알 수 없는 데이터 소스: ${dataSource}, 기본값(form) 사용`); + return { + ...baseContext, + formData: context.formData || {}, + sourceData: [context.formData || {}], + }; + } } /** diff --git a/frontend/stores/flowStepStore.ts b/frontend/stores/flowStepStore.ts index 838baf6c..dba4b11d 100644 --- a/frontend/stores/flowStepStore.ts +++ b/frontend/stores/flowStepStore.ts @@ -49,47 +49,24 @@ export const useFlowStepStore = create()( selectedSteps: {}, setSelectedStep: (flowComponentId, stepId) => { - console.log("🔄 [FlowStepStore] 플로우 단계 변경:", { - flowComponentId, - stepId, - stepName: stepId ? `Step ${stepId}` : "선택 해제", - }); - set((state) => ({ selectedSteps: { ...state.selectedSteps, [flowComponentId]: stepId, }, })); - - // 개발 모드에서 현재 상태 출력 - if (process.env.NODE_ENV === "development") { - const currentState = get().selectedSteps; - console.log("📊 [FlowStepStore] 현재 상태:", currentState); - } }, getCurrentStep: (flowComponentId) => { const stepId = get().selectedSteps[flowComponentId] || null; - - if (process.env.NODE_ENV === "development") { - console.log("🔍 [FlowStepStore] 현재 단계 조회:", { - flowComponentId, - stepId, - }); - } - return stepId; }, reset: () => { - console.log("🔄 [FlowStepStore] 모든 플로우 단계 초기화"); set({ selectedSteps: {} }); }, resetFlow: (flowComponentId) => { - console.log("🔄 [FlowStepStore] 플로우 단계 초기화:", flowComponentId); - set((state) => { const { [flowComponentId]: _, ...rest } = state.selectedSteps; return { selectedSteps: rest }; diff --git a/frontend/types/control-management.ts b/frontend/types/control-management.ts index e64bac96..fc77f179 100644 --- a/frontend/types/control-management.ts +++ b/frontend/types/control-management.ts @@ -117,8 +117,23 @@ export interface ButtonDataflowConfig { /** * 제어 데이터 소스 타입 + * + * - form: 폼 데이터만 사용 + * - table-selection: 테이블에서 선택된 행 데이터만 사용 + * - table-all: 테이블의 전체 데이터 사용 (페이징 무관, 모든 데이터) + * - flow-selection: 플로우에서 선택된 데이터만 사용 + * - flow-step-all: 특정 플로우 스텝의 모든 데이터 사용 + * - both: 폼 + 테이블 선택 데이터 결합 + * - all-sources: 모든 소스 데이터 결합 (폼 + 테이블 전체 + 플로우) */ -export type ControlDataSource = "form" | "table-selection" | "flow-selection" | "both"; +export type ControlDataSource = + | "form" + | "table-selection" + | "table-all" + | "flow-selection" + | "flow-step-all" + | "both" + | "all-sources"; /** * 직접 제어 설정 diff --git a/노드_플로우_데이터소스_설정_가이드.md b/노드_플로우_데이터소스_설정_가이드.md new file mode 100644 index 00000000..6222cb74 --- /dev/null +++ b/노드_플로우_데이터소스_설정_가이드.md @@ -0,0 +1,346 @@ +# 노드 플로우 데이터 소스 설정 가이드 + +## 개요 + +노드 플로우 편집기에서 **테이블 소스 노드**와 **외부 DB 소스 노드**에 데이터 소스 타입을 설정할 수 있습니다. 이제 버튼에서 전달된 데이터를 사용할지, 아니면 테이블의 전체 데이터를 직접 조회할지 선택할 수 있습니다. + +## 지원 노드 + +### 1. 테이블 소스 노드 (내부 DB) +- **위치**: 노드 팔레트 > 데이터 소스 > 테이블 소스 +- **용도**: 내부 데이터베이스의 테이블 데이터 조회 + +### 2. 외부 DB 소스 노드 +- **위치**: 노드 팔레트 > 데이터 소스 > 외부 DB 소스 +- **용도**: 외부 데이터베이스의 테이블 데이터 조회 + +## 데이터 소스 타입 + +### 1. 컨텍스트 데이터 (기본값) +``` +💡 컨텍스트 데이터 모드 +버튼 실행 시 전달된 데이터(폼 데이터, 테이블 선택 항목 등)를 사용합니다. + +사용 예시: +• 폼 데이터: 1개 레코드 +• 테이블 선택: N개 레코드 +``` + +**특징:** +- ✅ 버튼에서 제어한 데이터만 처리 +- ✅ 성능 우수 (필요한 데이터만 사용) +- ✅ 사용자가 선택한 데이터만 처리 +- ⚠️ 버튼 설정에서 데이터 소스를 올바르게 설정해야 함 + +**사용 시나리오:** +- 폼 데이터로 새 레코드 생성 +- 테이블에서 선택한 항목 일괄 업데이트 +- 사용자가 선택한 데이터만 처리 + +### 2. 테이블 전체 데이터 +``` +📊 테이블 전체 데이터 모드 +선택한 테이블의 **모든 행**을 직접 조회합니다. + +⚠️ 대량 데이터 시 성능 주의 +``` + +**특징:** +- ✅ 테이블의 모든 데이터 처리 +- ✅ 버튼 설정과 무관하게 동작 +- ✅ 자동으로 전체 데이터 조회 +- ⚠️ 대량 데이터 시 메모리 및 성능 이슈 가능 +- ⚠️ 네트워크 부하 증가 + +**사용 시나리오:** +- 전체 데이터 통계/집계 +- 일괄 데이터 마이그레이션 +- 전체 데이터 검증 +- 백업/복원 작업 + +## 설정 방법 + +### 1단계: 노드 추가 +1. 노드 플로우 편집기 열기 +2. 좌측 팔레트에서 **테이블 소스** 또는 **외부 DB 소스** 드래그 +3. 캔버스에 노드 배치 + +### 2단계: 테이블 선택 +1. 노드 클릭하여 선택 +2. 우측 **속성 패널** 열림 +3. **테이블 선택** 드롭다운에서 테이블 선택 + +### 3단계: 데이터 소스 설정 +1. **데이터 소스 설정** 섹션으로 스크롤 +2. **데이터 소스 타입** 드롭다운 클릭 +3. 원하는 모드 선택: + - **컨텍스트 데이터**: 버튼에서 전달된 데이터 사용 + - **테이블 전체 데이터**: 테이블의 모든 행 조회 + +### 4단계: 저장 +- 변경 사항은 **즉시 노드에 반영**됩니다. +- 별도 저장 버튼 불필요 (자동 저장) + +## 사용 예시 + +### 예시 1: 선택된 항목만 처리 (컨텍스트 데이터) + +**시나리오**: 사용자가 테이블에서 선택한 주문만 승인 처리 + +**플로우 구성:** +``` +[테이블 소스: orders] + └─ 데이터 소스: 컨텍스트 데이터 + └─ [조건: status = 'pending'] + └─ [업데이트: status = 'approved'] +``` + +**버튼 설정:** +- 제어 데이터 소스: `table-selection` (테이블 선택 항목) + +**실행 결과:** +- 사용자가 선택한 3개 주문만 승인 처리 +- 나머지 주문은 변경되지 않음 + +### 예시 2: 전체 데이터 일괄 처리 (테이블 전체 데이터) + +**시나리오**: 모든 고객의 등급을 재계산 + +**플로우 구성:** +``` +[테이블 소스: customers] + └─ 데이터 소스: 테이블 전체 데이터 + └─ [데이터 변환: 등급 계산] + └─ [업데이트: grade = 계산된 등급] +``` + +**버튼 설정:** +- 제어 데이터 소스: 무관 (테이블 전체를 자동 조회) + +**실행 결과:** +- 모든 고객 레코드의 등급 재계산 +- 1,000개 고객 → 1,000개 모두 업데이트 + +### 예시 3: 외부 DB 전체 데이터 동기화 + +**시나리오**: 외부 ERP의 모든 제품 정보를 내부 DB로 동기화 + +**플로우 구성:** +``` +[외부 DB 소스: products] + └─ 데이터 소스: 테이블 전체 데이터 + └─ [Upsert: 내부 DB products 테이블] +``` + +**실행 결과:** +- 외부 DB의 모든 제품 데이터 조회 +- 내부 DB에 동기화 (있으면 업데이트, 없으면 삽입) + +## 노드 실행 로직 + +### 컨텍스트 데이터 모드 실행 흐름 + +```typescript +// 1. 버튼 클릭 +// 2. 버튼에서 데이터 전달 (폼, 테이블 선택 등) +// 3. 노드 플로우 실행 +// 4. 테이블 소스 노드가 전달받은 데이터 사용 + +{ + nodeType: "tableSource", + config: { + tableName: "orders", + dataSourceType: "context-data" + }, + // 실행 시 버튼에서 전달된 데이터 사용 + input: [ + { id: 1, status: "pending" }, + { id: 2, status: "pending" } + ] +} +``` + +### 테이블 전체 데이터 모드 실행 흐름 + +```typescript +// 1. 버튼 클릭 +// 2. 노드 플로우 실행 +// 3. 테이블 소스 노드가 직접 DB 조회 +// 4. 모든 행을 반환 + +{ + nodeType: "tableSource", + config: { + tableName: "orders", + dataSourceType: "table-all" + }, + // 실행 시 DB에서 전체 데이터 조회 + query: "SELECT * FROM orders", + output: [ + { id: 1, status: "pending" }, + { id: 2, status: "approved" }, + { id: 3, status: "completed" }, + // ... 수천 개의 행 + ] +} +``` + +## 성능 고려사항 + +### 컨텍스트 데이터 모드 +- ✅ **성능 우수**: 필요한 데이터만 처리 +- ✅ **메모리 효율**: 선택된 데이터만 메모리에 로드 +- ✅ **네트워크 효율**: 최소한의 데이터 전송 + +### 테이블 전체 데이터 모드 +- ⚠️ **대량 데이터 주의**: 수천~수만 개 행 처리 시 느려질 수 있음 +- ⚠️ **메모리 사용**: 모든 데이터를 메모리에 로드 +- ⚠️ **네트워크 부하**: 전체 데이터 전송 + +**권장 사항:** +``` +• 데이터가 1,000개 이하: 테이블 전체 데이터 사용 가능 +• 데이터가 10,000개 이상: 컨텍스트 데이터 + 필터링 권장 +• 데이터가 100,000개 이상: 배치 처리 또는 서버 사이드 처리 필요 +``` + +## 디버깅 + +### 콘솔 로그 확인 + +**데이터 소스 타입 변경 시:** +``` +✅ 데이터 소스 타입 변경: table-all +``` + +**노드 실행 시:** +```typescript +// 컨텍스트 데이터 모드 +🔍 테이블 소스 노드 실행: orders +📊 입력 데이터: 3건 (컨텍스트에서 전달됨) + +// 테이블 전체 데이터 모드 +🔍 테이블 소스 노드 실행: orders +📊 테이블 전체 데이터 조회: 1,234건 +``` + +### 일반적인 문제 + +#### Q1: 컨텍스트 데이터 모드인데 데이터가 없습니다 +**A**: 버튼 설정을 확인하세요. +- 버튼 설정 > 제어 데이터 소스가 올바르게 설정되어 있는지 확인 +- 폼 데이터: `form` +- 테이블 선택: `table-selection` +- 테이블 전체: `table-all` + +#### Q2: 테이블 전체 데이터 모드가 느립니다 +**A**: +1. 데이터 양 확인 (몇 개 행인지?) +2. 필요하면 컨텍스트 데이터 + 필터링으로 변경 +3. WHERE 조건으로 범위 제한 + +#### Q3: 외부 DB 소스가 오래 걸립니다 +**A**: +1. 외부 DB 연결 상태 확인 +2. 네트워크 지연 확인 +3. 외부 DB의 인덱스 확인 + +## 버튼 설정과의 관계 + +### 버튼 데이터 소스 vs 노드 데이터 소스 + +| 버튼 설정 | 노드 설정 | 결과 | +|---------|---------|-----| +| `table-selection` | `context-data` | 선택된 항목만 처리 ✅ | +| `table-all` | `context-data` | 전체 데이터 전달됨 ⚠️ | +| 무관 | `table-all` | 노드가 직접 전체 조회 ✅ | +| `form` | `context-data` | 폼 데이터만 처리 ✅ | + +**권장 조합:** +``` +1. 선택된 항목 처리: + 버튼: table-selection → 노드: context-data + +2. 테이블 전체 처리: + 버튼: 무관 → 노드: table-all + +3. 폼 데이터 처리: + 버튼: form → 노드: context-data +``` + +## 마이그레이션 가이드 + +### 기존 노드 업데이트 + +기존에 생성된 노드는 **자동으로 `context-data` 모드**로 설정됩니다. + +**업데이트 방법:** +1. 노드 선택 +2. 속성 패널 열기 +3. 데이터 소스 설정 섹션에서 `table-all`로 변경 + +## 베스트 프랙티스 + +### ✅ 좋은 예 + +```typescript +// 시나리오: 사용자가 선택한 주문 취소 +[테이블 소스: orders] + dataSourceType: "context-data" // ✅ 선택된 주문만 처리 + ↓ +[업데이트: status = 'cancelled'] +``` + +```typescript +// 시나리오: 모든 만료된 쿠폰 삭제 +[테이블 소스: coupons] + dataSourceType: "table-all" // ✅ 전체 조회 후 필터링 + ↓ +[조건: expiry_date < today] + ↓ +[삭제] +``` + +### ❌ 나쁜 예 + +```typescript +// 시나리오: 단일 주문 업데이트인데 전체 조회 +[테이블 소스: orders] + dataSourceType: "table-all" // ❌ 불필요한 전체 조회 + ↓ +[조건: id = 123] // 한 개만 필요한데 전체를 조회함 + ↓ +[업데이트] +``` + +## 요약 + +### 언제 어떤 모드를 사용해야 하나요? + +| 상황 | 권장 모드 | +|------|----------| +| 폼 데이터로 새 레코드 생성 | 컨텍스트 데이터 | +| 테이블에서 선택한 항목 수정 | 컨텍스트 데이터 | +| 전체 데이터 통계/집계 | 테이블 전체 데이터 | +| 일괄 데이터 마이그레이션 | 테이블 전체 데이터 | +| 특정 조건의 데이터 처리 | 테이블 전체 데이터 + 조건 | +| 외부 DB 동기화 | 테이블 전체 데이터 | + +### 핵심 원칙 + +1. **기본은 컨텍스트 데이터**: 대부분의 경우 이것으로 충분합니다. +2. **전체 데이터는 신중히**: 성능 영향을 고려하세요. +3. **버튼과 노드를 함께 설계**: 데이터 흐름을 명확히 이해하세요. + +## 관련 문서 + +- [제어관리_데이터소스_확장_가이드.md](./제어관리_데이터소스_확장_가이드.md) - 버튼 데이터 소스 설정 +- 노드 플로우 기본 가이드 (준비 중) + +## 업데이트 이력 + +- **2025-01-24**: 초기 문서 작성 + - 테이블 소스 노드에 데이터 소스 타입 추가 + - 외부 DB 소스 노드에 데이터 소스 타입 추가 + - `context-data`, `table-all` 모드 지원 + diff --git a/데이터소스_일관성_개선_완료.md b/데이터소스_일관성_개선_완료.md new file mode 100644 index 00000000..1ee1c15a --- /dev/null +++ b/데이터소스_일관성_개선_완료.md @@ -0,0 +1,230 @@ +# 데이터 소스 일관성 개선 완료 + +## 문제점 + +기존에는 데이터 소스 설정이 일관성 없이 동작했습니다: + +- ❌ 테이블 위젯에서 선택한 행 → 노드는 선택된 행만 처리 +- ❌ 플로우 위젯에서 선택한 데이터 → 노드는 **전체 테이블** 조회 (예상과 다름) +- ❌ 노드에 `dataSourceType` 설정이 있어도 백엔드가 무시 + +## 해결 방법 + +### 1. 백엔드 로직 개선 + +#### 테이블 소스 노드 (내부 DB) + +```typescript +// nodeFlowExecutionService.ts - executeTableSource() + +const nodeDataSourceType = dataSourceType || "context-data"; + +if (nodeDataSourceType === "context-data") { + // 버튼에서 전달된 데이터 사용 (폼, 선택 항목 등) + return context.sourceData; +} + +if (nodeDataSourceType === "table-all") { + // 테이블 전체 데이터를 직접 조회 + const sql = `SELECT * FROM ${tableName}`; + return await query(sql); +} +``` + +#### 외부 DB 소스 노드 + +```typescript +// nodeFlowExecutionService.ts - executeExternalDBSource() + +const nodeDataSourceType = dataSourceType || "context-data"; + +if (nodeDataSourceType === "context-data") { + // 버튼에서 전달된 데이터 사용 + return context.sourceData; +} + +if (nodeDataSourceType === "table-all") { + // 외부 DB 테이블 전체 데이터를 직접 조회 + const result = await poolService.executeQuery(connectionId, sql); + return result; +} +``` + +### 2. 데이터 흐름 정리 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 버튼 클릭 │ +├──────────────────────────────────────────────────────────┤ +│ 버튼 데이터 소스 설정: │ +│ - form │ +│ - table-selection │ +│ - table-all │ +│ - flow-selection │ +│ - flow-step-all │ +└──────────────────────────────────────────────────────────┘ + ↓ + prepareContextData() + (버튼에서 설정한 데이터 준비) + ↓ +┌──────────────────────────────────────────────────────────┐ +│ contextData = { │ +│ sourceData: [...] // 버튼에서 전달된 데이터 │ +│ formData: {...} │ +│ selectedRowsData: [...] │ +│ tableAllData: [...] │ +│ } │ +└──────────────────────────────────────────────────────────┘ + ↓ + 노드 플로우 실행 + ↓ +┌──────────────────────────────────────────────────────────┐ +│ 테이블 소스 노드 │ +├──────────────────────────────────────────────────────────┤ +│ 노드 데이터 소스 설정: │ +│ │ +│ context-data 모드: │ +│ → contextData.sourceData 사용 │ +│ → 버튼에서 전달된 데이터 그대로 사용 │ +│ │ +│ table-all 모드: │ +│ → contextData 무시 │ +│ → DB에서 테이블 전체 데이터 직접 조회 │ +└──────────────────────────────────────────────────────────┘ +``` + +## 사용 시나리오 + +### 시나리오 1: 선택된 항목만 처리 + +``` +[버튼 설정] +- 데이터 소스: table-selection + +[노드 설정] +- 테이블 소스 노드: context-data + +[결과] +✅ 사용자가 선택한 행만 제어 실행 +``` + +### 시나리오 2: 테이블 전체 처리 (버튼 방식) + +``` +[버튼 설정] +- 데이터 소스: table-all + +[노드 설정] +- 테이블 소스 노드: context-data + +[결과] +✅ 버튼이 테이블 전체 데이터를 로드하여 전달 +✅ 노드는 전달받은 전체 데이터 처리 +``` + +### 시나리오 3: 테이블 전체 처리 (노드 방식) + +``` +[버튼 설정] +- 데이터 소스: 무관 (또는 form) + +[노드 설정] +- 테이블 소스 노드: table-all + +[결과] +✅ 버튼 데이터 무시 +✅ 노드가 직접 테이블 전체 데이터 조회 +``` + +### 시나리오 4: 폼 데이터로 처리 + +``` +[버튼 설정] +- 데이터 소스: form + +[노드 설정] +- 테이블 소스 노드: context-data + +[결과] +✅ 폼 입력값만 제어 실행 +``` + +## 일관성 규칙 + +### 규칙 1: 노드가 context-data 모드일 때 +- **버튼에서 전달된 데이터를 그대로 사용** +- 버튼의 `controlDataSource` 설정이 중요 +- `form` → 폼 데이터 사용 +- `table-selection` → 선택된 행 사용 +- `table-all` → 테이블 전체 사용 (버튼이 로드) +- `flow-selection` → 플로우 선택 항목 사용 + +### 규칙 2: 노드가 table-all 모드일 때 +- **버튼 설정 무시** +- 노드가 직접 DB에서 전체 데이터 조회 +- 대량 데이터 시 성능 주의 + +### 규칙 3: 기본 동작 +- 노드의 `dataSourceType`이 없으면 `context-data` 기본값 +- 버튼의 `controlDataSource`가 없으면 자동 판단 + +## 권장 사항 + +### 일반적인 사용 패턴 + +| 상황 | 버튼 설정 | 노드 설정 | +|------|----------|----------| +| 선택 항목 처리 | `table-selection` | `context-data` | +| 폼 데이터 처리 | `form` | `context-data` | +| 전체 데이터 처리 (소량) | `table-all` | `context-data` | +| 전체 데이터 처리 (대량) | `form` 또는 무관 | `table-all` | +| 플로우 선택 처리 | `flow-selection` | `context-data` | + +### 성능 고려사항 + +**버튼에서 전체 로드 vs 노드에서 전체 조회:** + +``` +버튼 방식 (table-all): + 장점: 한 번만 조회하여 여러 노드에서 재사용 가능 + 단점: 플로우 실행 전에 전체 데이터 로드 (시작 지연) + +노드 방식 (table-all): + 장점: 필요한 노드만 조회 (선택적 로드) + 단점: 여러 노드에서 사용 시 중복 조회 + +권장: 데이터가 많으면 노드 방식, 재사용이 많으면 버튼 방식 +``` + +## 로그 확인 + +### 성공적인 실행 예시 + +``` +📊 테이블 소스 노드 실행: orders, dataSourceType=context-data +📊 컨텍스트 데이터 사용: table-selection, 3건 +✅ 노드 실행 완료: 3건 처리 + +또는 + +📊 테이블 소스 노드 실행: customers, dataSourceType=table-all +📊 테이블 전체 데이터 조회: customers, 1,234건 +✅ 노드 실행 완료: 1,234건 처리 +``` + +### 문제가 있는 경우 + +``` +⚠️ context-data 모드이지만 전달된 데이터가 없습니다. 빈 배열 반환. + +해결: 버튼의 controlDataSource 설정 확인 +``` + +## 업데이트 내역 + +- **2025-01-24**: 백엔드 로직 개선 완료 + - `executeTableSource()` 함수에 `dataSourceType` 처리 추가 + - `executeExternalDBSource()` 함수에 `dataSourceType` 처리 추가 + - 노드 설정이 올바르게 반영되도록 수정 + - 일관성 있는 데이터 흐름 확립 + diff --git a/스크롤_문제_해결_가이드.md b/스크롤_문제_해결_가이드.md new file mode 100644 index 00000000..9a59bc6e --- /dev/null +++ b/스크롤_문제_해결_가이드.md @@ -0,0 +1,203 @@ +# 속성 패널 스크롤 문제 해결 가이드 + +## 적용된 수정사항 + +### 1. PropertiesPanel.tsx +```tsx +// 최상위 컨테이너 +
+ +// 헤더 (고정 높이) +
+ +// 스크롤 영역 (중요!) +
+``` + +### 2. FlowEditor.tsx +```tsx +// 속성 패널 컨테이너 단순화 +
+ +
+``` + +### 3. TableSourceProperties.tsx / ExternalDBSourceProperties.tsx +```tsx +// ScrollArea 제거, 일반 div 사용 +
+ {/* 컨텐츠 */} +
+``` + +## 테스트 방법 + +1. **브라우저 강제 새로고침** + - Windows: `Ctrl + Shift + R` 또는 `Ctrl + F5` + - Mac: `Cmd + Shift + R` + +2. **노드 플로우 편집기 열기** + - 관리자 메뉴 > 플로우 관리 + +3. **테스트 노드 추가** + - 테이블 소스 노드를 캔버스에 드래그 + +4. **속성 패널 확인** + - 노드 클릭 + - 우측에 속성 패널 열림 + - **회색 배경 확인** (스크롤 영역) + +5. **스크롤 테스트** + - 마우스 휠로 스크롤 + - 또는 스크롤바 드래그 + - **빨간 박스** → 중간 지점 + - **파란 박스** → 맨 아래 (스크롤 성공!) + +## 스크롤이 여전히 안 되는 경우 + +### 체크리스트 + +1. ✅ **브라우저 캐시 완전 삭제** + ``` + F12 > Network 탭 > "Disable cache" 체크 + ``` + +2. ✅ **개발자 도구로 HTML 구조 확인** + ``` + F12 > Elements 탭 + 속성 패널의 div 찾기 + → "overflow-y: scroll" 스타일 확인 + ``` + +3. ✅ **콘솔 에러 확인** + ``` + F12 > Console 탭 + 에러 메시지 확인 + ``` + +4. ✅ **브라우저 호환성** + - Chrome/Edge: 권장 + - Firefox: 지원 + - Safari: 일부 스타일 이슈 가능 + +### 디버깅 가이드 + +**단계 1: HTML 구조 확인** +```html + +
+
+
+
+ +
+
+
+``` + +**단계 2: CSS 스타일 확인** +```css +/* 스크롤 영역에 있어야 할 스타일 */ +overflow-y: scroll; +max-height: calc(100vh - 64px); +flex: 1 1 0%; +``` + +**단계 3: 컨텐츠 높이 확인** +``` +스크롤이 생기려면: +컨텐츠 높이 > 컨테이너 높이 +``` + +## 시각적 표시 + +현재 테스트용으로 추가된 표시들: + +1. **노란색 박스** (맨 위) + - "📏 스크롤 테스트: 이 패널은 스크롤 가능해야 합니다" + +2. **회색 배경** (전체 스크롤 영역) + - `bg-gray-50` 클래스 + +3. **빨간색 박스** (중간) + - "🚨 스크롤 테스트: 이 빨간 박스가 보이면 스크롤이 작동하는 것입니다!" + +4. **20개 테스트 항목** (중간 ~ 아래) + - "테스트 항목 1" ~ "테스트 항목 20" + +5. **파란색 박스** (맨 아래) + - "🎉 맨 아래 도착! 이 파란 박스가 보이면 스크롤이 완벽하게 작동합니다!" + +## 제거할 테스트 코드 + +스크롤이 확인되면 다음 코드를 제거하세요: + +### TableSourceProperties.tsx +```tsx +// 제거할 부분 1 (줄 172-174) +
+ 📏 스크롤 테스트: 이 패널은 스크롤 가능해야 합니다 +
+ +// 제거할 부분 2 (줄 340-357) +
+
+ {/* ... */} +
+ {[...Array(20)].map((_, i) => (/* ... */))} +
+ {/* ... */} +
+
+``` + +### PropertiesPanel.tsx +```tsx +// bg-gray-50 제거 (줄 47) +// 변경 전: className="flex-1 overflow-y-scroll bg-gray-50" +// 변경 후: className="flex-1 overflow-y-scroll" +``` + +## 핵심 원리 + +``` +┌─────────────────────────────────┐ +│ FlowEditor (h-full) │ +│ ┌─────────────────────────────┐ │ +│ │ PropertiesPanel (h-full) │ │ +│ │ ┌─────────────────────────┐ │ │ +│ │ │ 헤더 (h-16, shrink-0) │ │ │ ← 고정 64px +│ │ └─────────────────────────┘ │ │ +│ │ ┌─────────────────────────┐ │ │ +│ │ │ 스크롤 영역 │ │ │ +│ │ │ (flex-1, overflow-y) │ │ │ +│ │ │ │ │ │ +│ │ │ ↓ 컨텐츠가 넘치면 │ │ │ +│ │ │ ↓ 스크롤바 생성! │ │ │ +│ │ │ │ │ │ +│ │ └─────────────────────────┘ │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────┘ + +flex-1 = 남은 공간을 모두 차지 +overflow-y: scroll = 세로 스크롤 강제 표시 +maxHeight = 넘칠 경우를 대비한 최대 높이 +``` + +## 마지막 체크포인트 + +스크롤이 작동하는지 확인하는 3가지 방법: + +1. ✅ **마우스 휠**: 속성 패널 위에서 휠 스크롤 +2. ✅ **스크롤바**: 우측에 스크롤바가 보이면 드래그 +3. ✅ **키보드**: Page Up/Down 키 또는 방향키 + +하나라도 작동하면 성공입니다! + diff --git a/제어관리_데이터소스_확장_가이드.md b/제어관리_데이터소스_확장_가이드.md new file mode 100644 index 00000000..d9f57fb2 --- /dev/null +++ b/제어관리_데이터소스_확장_가이드.md @@ -0,0 +1,500 @@ +# 제어관리 데이터 소스 확장 가이드 + +## 개요 + +제어관리(플로우) 실행 시 사용할 수 있는 데이터 소스가 확장되었습니다. 이제 **폼 데이터**, **테이블 선택 항목**, **테이블 전체 데이터**, **플로우 선택 항목**, **플로우 스텝 전체 데이터** 등 다양한 소스에서 데이터를 가져와 제어를 실행할 수 있습니다. + +## 지원 데이터 소스 + +### 1. `form` - 폼 데이터 +- **설명**: 현재 화면의 폼 입력값을 사용합니다. +- **사용 시나리오**: 단일 레코드 생성/수정 시 +- **데이터 형태**: 단일 객체 + +```typescript +{ + name: "홍길동", + age: 30, + email: "test@example.com" +} +``` + +### 2. `table-selection` - 테이블 선택 항목 +- **설명**: 테이블에서 사용자가 선택한 행의 데이터를 사용합니다. +- **사용 시나리오**: 선택된 항목에 대한 일괄 처리 +- **데이터 형태**: 배열 (선택된 행들) + +```typescript +[ + { id: 1, name: "항목1", status: "대기" }, + { id: 2, name: "항목2", status: "대기" } +] +``` + +### 3. `table-all` - 테이블 전체 데이터 🆕 +- **설명**: 테이블의 **모든 데이터**를 사용합니다 (페이징 무관). +- **사용 시나리오**: + - 전체 데이터에 대한 일괄 처리 + - 통계/집계 작업 + - 대량 데이터 마이그레이션 +- **데이터 형태**: 배열 (전체 행) +- **주의사항**: 데이터가 많을 경우 성능 이슈가 있을 수 있습니다. + +```typescript +[ + { id: 1, name: "항목1", status: "대기" }, + { id: 2, name: "항목2", status: "진행중" }, + { id: 3, name: "항목3", status: "완료" }, + // ... 수천 개의 행 +] +``` + +### 4. `flow-selection` - 플로우 선택 항목 +- **설명**: 플로우 위젯에서 사용자가 선택한 데이터를 사용합니다. +- **사용 시나리오**: 플로우 단계별로 선택된 항목 처리 +- **데이터 형태**: 배열 (선택된 행들) + +```typescript +[ + { id: 10, taskName: "작업1", stepId: 2 }, + { id: 11, taskName: "작업2", stepId: 2 } +] +``` + +### 5. `flow-step-all` - 플로우 스텝 전체 데이터 🆕 +- **설명**: 현재 선택된 플로우 단계의 **모든 데이터**를 사용합니다. +- **사용 시나리오**: + - 특정 단계의 모든 항목 일괄 처리 + - 단계별 완료율 계산 + - 단계 이동 시 전체 데이터 마이그레이션 +- **데이터 형태**: 배열 (해당 스텝의 전체 행) + +```typescript +[ + { id: 10, taskName: "작업1", stepId: 2, assignee: "홍길동" }, + { id: 11, taskName: "작업2", stepId: 2, assignee: "김철수" }, + { id: 12, taskName: "작업3", stepId: 2, assignee: "이영희" }, + // ... 해당 스텝의 모든 데이터 +] +``` + +### 6. `both` - 폼 + 테이블 선택 +- **설명**: 폼 데이터와 테이블 선택 항목을 결합하여 사용합니다. +- **사용 시나리오**: 폼의 공통 정보 + 개별 항목 처리 +- **데이터 형태**: 배열 (폼 데이터 + 선택된 행들) + +```typescript +[ + { name: "홍길동", age: 30 }, // 폼 데이터 + { id: 1, name: "항목1", status: "대기" }, + { id: 2, name: "항목2", status: "대기" } +] +``` + +### 7. `all-sources` - 모든 소스 결합 🆕 +- **설명**: 폼, 테이블 전체, 플로우 등 **모든 소스의 데이터를 결합**하여 사용합니다. +- **사용 시나리오**: + - 복잡한 데이터 통합 작업 + - 다중 소스 동기화 + - 전체 시스템 상태 업데이트 +- **데이터 형태**: 배열 (모든 소스의 데이터 병합) +- **주의사항**: 매우 많은 데이터가 전달될 수 있으므로 신중히 사용하세요. + +```typescript +[ + { name: "홍길동", age: 30 }, // 폼 데이터 + { id: 1, name: "테이블1" }, // 테이블 선택 + { id: 2, name: "테이블2" }, // 테이블 선택 + { id: 3, name: "테이블3" }, // 테이블 전체 + { id: 10, taskName: "작업1" }, // 플로우 선택 + // ... 모든 소스의 데이터 +] +``` + +## 설정 방법 + +### 1. 버튼 상세 설정에서 데이터 소스 선택 + +1. 화면 디자이너에서 버튼 선택 +2. 우측 패널 > **상세 설정** 탭 +3. **제어관리 활성화** 체크 +4. **제어 데이터 소스** 드롭다운에서 원하는 소스 선택 + +### 2. 데이터 소스 옵션 + +``` +┌─────────────────────────────────────┐ +│ 제어 데이터 소스 │ +├─────────────────────────────────────┤ +│ 📄 폼 데이터 │ +│ 📊 테이블 선택 항목 │ +│ 📊 테이블 전체 데이터 🆕 │ +│ 🔄 플로우 선택 항목 │ +│ 🔄 플로우 스텝 전체 데이터 🆕 │ +│ 📋 폼 + 테이블 선택 │ +│ 🌐 모든 소스 결합 🆕 │ +└─────────────────────────────────────┘ +``` + +## 실제 사용 예시 + +### 예시 1: 테이블 전체 데이터로 일괄 상태 업데이트 + +```typescript +// 제어 설정 +{ + controlDataSource: "table-all", + flowConfig: { + flowId: 10, + flowName: "전체 항목 승인 처리", + executionTiming: "replace" + } +} + +// 실행 시 전달되는 데이터 +{ + buttonId: "btn_approve_all", + sourceData: [ + { id: 1, name: "항목1", status: "대기" }, + { id: 2, name: "항목2", status: "대기" }, + { id: 3, name: "항목3", status: "대기" }, + // ... 테이블의 모든 행 (1000개) + ] +} +``` + +### 예시 2: 플로우 스텝 전체를 다음 단계로 이동 + +```typescript +// 제어 설정 +{ + controlDataSource: "flow-step-all", + flowConfig: { + flowId: 15, + flowName: "단계 일괄 이동", + executionTiming: "replace" + } +} + +// 실행 시 전달되는 데이터 +{ + buttonId: "btn_move_all", + flowStepId: 2, + sourceData: [ + { id: 10, taskName: "작업1", stepId: 2 }, + { id: 11, taskName: "작업2", stepId: 2 }, + { id: 12, taskName: "작업3", stepId: 2 }, + // ... 해당 스텝의 모든 데이터 + ] +} +``` + +### 예시 3: 선택된 항목만 처리 + +```typescript +// 제어 설정 +{ + controlDataSource: "table-selection", + flowConfig: { + flowId: 5, + flowName: "선택 항목 승인", + executionTiming: "replace" + } +} + +// 실행 시 전달되는 데이터 (사용자가 2개 선택한 경우) +{ + buttonId: "btn_approve_selected", + sourceData: [ + { id: 1, name: "항목1", status: "대기" }, + { id: 5, name: "항목5", status: "대기" } + ] +} +``` + +## 데이터 로딩 방식 + +### 자동 로딩 vs 수동 로딩 + +1. **테이블 선택 항목** (`table-selection`) + - ✅ 자동 로딩: 사용자가 이미 선택한 데이터 사용 + - 별도 로딩 불필요 + +2. **테이블 전체 데이터** (`table-all`) + - ⚡ 지연 로딩: 버튼 클릭 시 필요한 경우만 로드 + - 부모 컴포넌트에서 `onRequestTableAllData` 콜백 제공 필요 + +3. **플로우 스텝 전체 데이터** (`flow-step-all`) + - ⚡ 지연 로딩: 버튼 클릭 시 필요한 경우만 로드 + - 부모 컴포넌트에서 `onRequestFlowStepAllData` 콜백 제공 필요 + +### 부모 컴포넌트 구현 예시 + +```tsx + { + const response = await fetch(`/api/data/table/${tableId}?all=true`); + const data = await response.json(); + return data.records; + }} + + // 플로우 스텝 전체 데이터 로드 콜백 + onRequestFlowStepAllData={async (stepId) => { + const response = await fetch(`/api/flow/step/${stepId}/all-data`); + const data = await response.json(); + return data.records; + }} +/> +``` + +## 성능 고려사항 + +### 1. 대량 데이터 처리 + +- **테이블 전체 데이터**: 수천 개의 행이 있을 경우 메모리 및 네트워크 부담 +- **해결 방법**: + - 배치 처리 사용 + - 페이징 처리 + - 서버 사이드 처리 + +### 2. 로딩 시간 + +```typescript +// ❌ 나쁜 예: 모든 데이터를 항상 미리 로드 +useEffect(() => { + loadTableAllData(); // 버튼을 누르지 않아도 로드됨 +}, []); + +// ✅ 좋은 예: 필요할 때만 로드 (지연 로딩) +const onRequestTableAllData = async () => { + return await loadTableAllData(); // 버튼 클릭 시에만 로드 +}; +``` + +### 3. 캐싱 + +```typescript +// 전체 데이터를 캐싱하여 재사용 +const [cachedTableAllData, setCachedTableAllData] = useState([]); + +const onRequestTableAllData = async () => { + if (cachedTableAllData.length > 0) { + console.log("캐시된 데이터 사용"); + return cachedTableAllData; + } + + const data = await loadTableAllData(); + setCachedTableAllData(data); + return data; +}; +``` + +## 노드 플로우에서 데이터 사용 + +### contextData 구조 + +노드 플로우 실행 시 전달되는 `contextData`는 다음과 같은 구조를 가집니다: + +```typescript +{ + buttonId: "btn_approve", + screenId: 123, + companyCode: "DEFAULT", + userId: "user001", + controlDataSource: "table-all", + + // 공통 데이터 + formData: { name: "홍길동" }, + + // 소스별 데이터 + selectedRowsData: [...], // table-selection + tableAllData: [...], // table-all + flowSelectedData: [...], // flow-selection + flowStepAllData: [...], // flow-step-all + flowStepId: 2, // 현재 플로우 스텝 ID + + // 통합 데이터 (모든 노드에서 사용 가능) + sourceData: [...] // controlDataSource에 따라 결정된 데이터 +} +``` + +### 노드에서 데이터 접근 + +```typescript +// External Call 노드 +{ + nodeType: "external-call", + config: { + url: "https://api.example.com/bulk-approve", + method: "POST", + body: { + // sourceData를 사용하여 데이터 전달 + items: "{{sourceData}}", + approver: "{{formData.approver}}" + } + } +} + +// DDL 노드 +{ + nodeType: "ddl", + config: { + sql: ` + UPDATE tasks + SET status = 'approved' + WHERE id IN ({{sourceData.map(d => d.id).join(',')}}) + ` + } +} +``` + +## 디버깅 및 로그 + +### 콘솔 로그 확인 + +버튼 클릭 시 다음과 같은 로그가 출력됩니다: + +``` +📊 데이터 소스 모드: { + controlDataSource: "table-all", + hasFormData: true, + hasTableSelection: false, + hasFlowSelection: false +} + +📊 테이블 전체 데이터 로드 중... +✅ 테이블 전체 데이터 1,234건 로드 완료 + +🚀 노드 플로우 실행 시작: { + flowId: 10, + flowName: "전체 항목 승인", + timing: "replace", + sourceDataCount: 1234 +} +``` + +### 에러 처리 + +```typescript +// 데이터 로드 실패 시 +❌ 테이블 전체 데이터 로드 실패: Network error +🔔 Toast: "테이블 전체 데이터를 불러오지 못했습니다" + +// 플로우 실행 실패 시 +❌ 플로우 실행 실패: 조건 불만족 +🔔 Toast: "테이블 전체 조건 불만족: status === 'pending' (실제값: approved)" +``` + +## 마이그레이션 가이드 + +### 기존 설정에서 업그레이드 + +기존에 `table-selection`을 사용하던 버튼을 `table-all`로 변경하는 경우: + +1. **버튼 설정 변경**: `table-selection` → `table-all` +2. **부모 컴포넌트 업데이트**: `onRequestTableAllData` 콜백 추가 +3. **노드 플로우 업데이트**: 대량 데이터 처리 로직 추가 +4. **테스트**: 소량 데이터로 먼저 테스트 후 전체 적용 + +### 하위 호환성 + +- ✅ 기존 `form`, `table-selection`, `both` 설정은 그대로 동작 +- ✅ 새로운 데이터 소스는 선택적으로 사용 가능 +- ✅ 기존 노드 플로우는 수정 없이 동작 + +## 베스트 프랙티스 + +### 1. 적절한 데이터 소스 선택 + +| 시나리오 | 권장 데이터 소스 | +|---------|----------------| +| 단일 레코드 생성/수정 | `form` | +| 선택된 항목 일괄 처리 | `table-selection` | +| 전체 항목 일괄 처리 | `table-all` | +| 플로우 단계별 선택 처리 | `flow-selection` | +| 플로우 단계 전체 이동 | `flow-step-all` | +| 복잡한 통합 작업 | `all-sources` | + +### 2. 성능 최적화 + +```typescript +// ✅ 좋은 예: 배치 처리 +const batchSize = 100; +for (let i = 0; i < sourceData.length; i += batchSize) { + const batch = sourceData.slice(i, i + batchSize); + await processBatch(batch); +} + +// ❌ 나쁜 예: 동기 처리 +for (const item of sourceData) { + await processItem(item); // 1000개면 1000번 API 호출 +} +``` + +### 3. 사용자 피드백 + +```typescript +// 대량 데이터 처리 시 진행률 표시 +toast.info(`${processed}/${total} 항목 처리 중...`, { + id: "batch-progress" +}); +``` + +## 문제 해결 + +### Q1: 테이블 전체 데이터가 로드되지 않습니다 + +**A**: 부모 컴포넌트에 `onRequestTableAllData` 콜백이 구현되어 있는지 확인하세요. + +```tsx +// InteractiveScreenViewer.tsx 확인 + { + // 이 함수가 구현되어 있어야 함 + return await fetchAllData(); + }} +/> +``` + +### Q2: 플로우 스텝 전체 데이터가 빈 배열입니다 + +**A**: +1. 플로우 스텝이 선택되어 있는지 확인 +2. `flowSelectedStepId`가 올바르게 전달되는지 확인 +3. `onRequestFlowStepAllData` 콜백이 구현되어 있는지 확인 + +### Q3: 데이터가 너무 많아 브라우저가 느려집니다 + +**A**: +1. 서버 사이드 처리 고려 +2. 배치 처리 사용 +3. 페이징 적용 +4. `table-selection` 사용 권장 (전체 대신 선택) + +## 관련 파일 + +### 타입 정의 +- `frontend/types/control-management.ts` - `ControlDataSource` 타입 + +### 핵심 로직 +- `frontend/lib/utils/nodeFlowButtonExecutor.ts` - 데이터 준비 및 전달 +- `frontend/components/screen/OptimizedButtonComponent.tsx` - 버튼 컴포넌트 + +### UI 설정 +- `frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx` - 설정 패널 + +### 서비스 +- `frontend/lib/services/optimizedButtonDataflowService.ts` - 데이터 검증 및 처리 + +## 업데이트 이력 + +- **2025-01-24**: 초기 문서 작성 + - `table-all` 데이터 소스 추가 + - `flow-step-all` 데이터 소스 추가 + - `all-sources` 데이터 소스 추가 + - 지연 로딩 메커니즘 구현 +