제어관리 개선판

This commit is contained in:
kjs 2025-10-24 14:11:12 +09:00
parent 96252270d7
commit 8d1f0e7098
30 changed files with 2285 additions and 655 deletions

View File

@ -677,51 +677,100 @@ export class NodeFlowExecutionService {
node: FlowNode,
context: ExecutionContext
): Promise<any[]> {
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<any[]> {
// 🔥 외부에서 주입된 데이터가 있으면 우선 사용
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<string[]> {
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<any[]> {
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,

View File

@ -423,6 +423,7 @@ export default function ScreenViewPage() {
<DynamicComponentRenderer
component={relativeButton}
isDesignMode={false}
isInteractive={true}
formData={formData}
onDataflowComplete={() => {}}
screenId={screenId}

View File

@ -28,9 +28,9 @@ export function PropertiesPanel() {
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
return (
<div className="flex h-full flex-col">
<div className="flex h-full w-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-white p-4">
<div>
<h3 className="text-sm font-semibold text-gray-900"></h3>
{selectedNode && (
@ -42,8 +42,15 @@ export function PropertiesPanel() {
</Button>
</div>
{/* 내용 */}
<div className="flex-1 overflow-hidden">
{/* 내용 - 스크롤 가능 영역 */}
<div
className="flex-1 overflow-y-scroll"
style={{
maxHeight: 'calc(100vh - 64px)',
overflowY: 'scroll',
WebkitOverflowScrolling: 'touch'
}}
>
{selectedNodes.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<div className="text-center text-sm text-gray-500">

View File

@ -29,7 +29,7 @@ export function CommentProperties({ nodeId, data }: CommentPropertiesProps) {
};
return (
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
<div className="flex items-center gap-2 rounded-md bg-yellow-50 p-2">
<MessageSquare className="h-4 w-4 text-yellow-600" />
<span className="font-semibold text-yellow-600"></span>

View File

@ -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 (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
@ -215,7 +228,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="mt-1"
placeholder="노드 표시 이름"
/>
@ -225,7 +238,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
<Label htmlFor="logic" className="text-xs">
</Label>
<Select value={logic} onValueChange={(value: "AND" | "OR") => setLogic(value)}>
<Select value={logic} onValueChange={handleLogicChange}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
@ -386,12 +399,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
)}
</div>
{/* 저장 버튼 */}
<div className="flex gap-2">
<Button onClick={handleSave} className="flex-1" size="sm">
</Button>
</div>
{/* 안내 */}
<div className="space-y-2">

View File

@ -359,7 +359,7 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
<Wand2 className="h-4 w-4 text-indigo-600" />
@ -453,13 +453,6 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
)}
</div>
{/* 적용 버튼 */}
<div className="sticky bottom-0 border-t bg-white pt-3">
<Button onClick={handleSave} className="w-full" size="sm">
</Button>
<p className="mt-2 text-center text-xs text-gray-500"> .</p>
</div>
</div>
</ScrollArea>
);

View File

@ -217,7 +217,7 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 경고 */}
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-3">
@ -706,9 +706,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
)}
</div>
<Button onClick={handleSave} variant="destructive" className="w-full" size="sm">
</Button>
<div className="space-y-2">
<div className="rounded bg-red-50 p-3 text-xs text-red-700">

View File

@ -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";
@ -44,6 +44,11 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
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<ExternalConnection[]>([]);
const [tables, setTables] = useState<ExternalTable[]>([]);
const [columns, setColumns] = useState<ExternalColumn[]>([]);
@ -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 (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* DB 타입 정보 */}
<div
className="rounded-lg border-2 p-4"
@ -302,7 +312,7 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="mt-1"
placeholder="노드 표시 이름"
/>
@ -340,6 +350,64 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
</div>
)}
{/* 🆕 데이터 소스 설정 */}
{tableName && (
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="context-data">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs">
</span>
</div>
</div>
</SelectItem>
<SelectItem value="table-all">
<div className="flex items-center gap-2">
<Table className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs">
DB의
</span>
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
{/* 설명 텍스트 */}
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
{dataSourceType === "context-data" ? (
<>
<p className="font-medium mb-1">💡 </p>
<p> .</p>
</>
) : (
<>
<p className="font-medium mb-1">📊 </p>
<p> DB의 ** ** .</p>
<p className="mt-1 text-orange-600 font-medium"> </p>
</>
)}
</div>
</div>
</div>
</div>
)}
{/* 컬럼 정보 */}
{columns.length > 0 && (
<div>
@ -347,14 +415,16 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
{loadingColumns ? (
<p className="text-xs text-gray-500"> ... </p>
) : (
<div className="max-h-[200px] space-y-1 overflow-y-auto">
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
{columns.map((col, index) => (
<div
key={index}
className="flex items-center justify-between rounded border bg-gray-50 px-3 py-2 text-xs"
className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs"
>
<span className="font-medium">{col.column_name}</span>
<span className="font-mono text-gray-500">{col.data_type}</span>
<span className="truncate font-medium" title={col.column_name}>
{col.column_name}
</span>
<span className="ml-2 shrink-0 font-mono text-gray-500">{col.data_type}</span>
</div>
))}
</div>
@ -362,14 +432,9 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
</div>
)}
<Button onClick={handleSave} className="w-full" size="sm">
</Button>
<div className="rounded p-3 text-xs" style={{ backgroundColor: `${dbInfo.color}15`, color: dbInfo.color }}>
💡 DB "외부 DB 연결 관리" .
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@ -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 (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 🔥 타겟 타입 선택 */}
<div>
<Label className="mb-2 block text-xs font-medium"> </Label>
@ -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
<Checkbox
id="ignoreDuplicates"
checked={ignoreDuplicates}
onCheckedChange={(checked) => setIgnoreDuplicates(checked as boolean)}
onCheckedChange={(checked) => handleIgnoreDuplicatesChange(checked as boolean)}
/>
<Label htmlFor="ignoreDuplicates" className="text-xs font-normal">
@ -1240,7 +1274,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
<Checkbox
id="ignoreErrors"
checked={ignoreErrors}
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
/>
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
@ -1250,11 +1284,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</div>
{/* 저장 버튼 */}
<div className="flex gap-2">
<Button onClick={handleSave} className="flex-1" size="sm">
</Button>
</div>
{/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700">

View File

@ -47,7 +47,7 @@ export function LogProperties({ nodeId, data }: LogPropertiesProps) {
const LevelIcon = selectedLevel?.icon || Info;
return (
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
<div className="flex items-center gap-2 rounded-md bg-gray-50 p-2">
<FileText className="h-4 w-4 text-gray-600" />
<span className="font-semibold text-gray-600"></span>

View File

@ -263,7 +263,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
@ -619,11 +619,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
</div>
{/* 저장 버튼 */}
<div className="flex gap-2">
<Button onClick={handleSave} className="flex-1" size="sm">
</Button>
</div>
{/* 안내 */}
<div className="space-y-2">

View File

@ -124,7 +124,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
};
return (
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
<Globe className="h-4 w-4 text-teal-600" />
<span className="font-semibold text-teal-600">REST API </span>

View File

@ -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";
@ -35,6 +36,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<TableOption[]>([]);
const [loading, setLoading] = useState(false);
@ -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 (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
@ -237,15 +254,77 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
</div>
</div>
{/* 🆕 데이터 소스 설정 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="context-data">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs">
(, )
</span>
</div>
</div>
</SelectItem>
<SelectItem value="table-all">
<div className="flex items-center gap-2">
<Table className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs">
( )
</span>
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
{/* 설명 텍스트 */}
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
{dataSourceType === "context-data" ? (
<>
<p className="font-medium mb-1">💡 </p>
<p> ( , ) .</p>
<p className="mt-1 text-blue-600"> 데이터: 1개 </p>
<p className="text-blue-600"> 선택: N개 </p>
</>
) : (
<>
<p className="font-medium mb-1">📊 </p>
<p> ** ** .</p>
<p className="mt-1 text-orange-600 font-medium"> </p>
</>
)}
</div>
</div>
</div>
</div>
{/* 필드 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<h3 className="mb-3 text-sm font-semibold">
{data.fields && data.fields.length > 0 && `(${data.fields.length}개)`}
</h3>
{data.fields && data.fields.length > 0 ? (
<div className="space-y-1 rounded border p-2">
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
{data.fields.map((field) => (
<div key={field.name} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-700">{field.name}</span>
<span className="text-gray-400">{field.type}</span>
<div key={field.name} className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs">
<span className="truncate font-mono text-gray-700" title={field.name}>
{field.name}
</span>
<span className="ml-2 shrink-0 text-gray-400">{field.type}</span>
</div>
))}
</div>
@ -254,9 +333,6 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
)}
</div>
{/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700"> .</div>
</div>
</ScrollArea>
</div>
);
}

View File

@ -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 (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
@ -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
<Checkbox
id="ignoreErrors"
checked={ignoreErrors}
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
/>
<Label htmlFor="ignoreErrors" className="cursor-pointer text-xs font-normal">
@ -1292,13 +1299,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
</div>
</div>
{/* 적용 버튼 */}
<div className="sticky bottom-0 border-t bg-white pt-3">
<Button onClick={handleSave} className="w-full" size="sm">
</Button>
<p className="mt-2 text-center text-xs text-gray-500"> .</p>
</div>
</div>
</ScrollArea>
);

View File

@ -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 (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
@ -1145,13 +1153,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
</div>
</div>
{/* 적용 버튼 */}
<div className="sticky bottom-0 border-t bg-white pt-3">
<Button onClick={handleSave} className="w-full" size="sm">
</Button>
<p className="mt-2 text-center text-xs text-gray-500"> .</p>
</div>
</div>
</ScrollArea>
);

View File

@ -27,6 +27,18 @@ interface OptimizedButtonProps {
selectedRowsData?: any[];
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 테이블 전체 데이터 (table-all 모드용)
tableAllData?: any[];
// 🆕 플로우 스텝 전체 데이터 (flow-step-all 모드용)
flowStepAllData?: any[];
// 🆕 테이블 전체 데이터 로드 콜백 (필요 시 부모에서 제공)
onRequestTableAllData?: () => Promise<any[]>;
// 🆕 플로우 스텝 전체 데이터 로드 콜백 (필요 시 부모에서 제공)
onRequestFlowStepAllData?: (stepId: number) => Promise<any[]>;
}
/**
@ -50,6 +62,10 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
selectedRowsData = [],
flowSelectedData = [],
flowSelectedStepId = null,
tableAllData = [],
flowStepAllData = [],
onRequestTableAllData,
onRequestFlowStepAllData,
}) => {
// 🔥 상태 관리
const [isExecuting, setIsExecuting] = useState(false);
@ -161,6 +177,47 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
// 🆕 노드 플로우 방식 실행
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<OptimizedButtonProps> = ({
formData,
selectedRows: selectedRows || [],
selectedRowsData: selectedRowsData || [],
flowSelectedData: flowSelectedData || [],
flowStepId: flowSelectedStepId || undefined,
controlDataSource: config.dataflowConfig.controlDataSource,
// 🆕 확장된 데이터 소스
tableAllData: preparedTableAllData,
flowStepAllData: preparedFlowStepAllData,
},
// 원래 액션 (timing이 before나 after일 때 실행)
async () => {

View File

@ -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 <div className="text-xs text-gray-500"> </div>;
@ -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<RealtimePreviewProps> = ({
component,
isSelected = false,
isDesignMode = false,
onClick,
onDragStart,
onDragEnd,
@ -515,7 +519,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
{type === "widget" && !isFileComponent(component) && (
<div className="pointer-events-none h-full w-full">
<WidgetRenderer component={component} />
<WidgetRenderer component={component} isDesignMode={isDesignMode} />
</div>
)}

View File

@ -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<ButtonDataflowConfigPanelProps>
<SelectItem value="form">
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4" />
<span> </span>
<span> </span>
</div>
</SelectItem>
<SelectItem value="table-selection">
<div className="flex items-center space-x-2">
<Table className="h-4 w-4" />
<span> </span>
<span> </span>
</div>
</SelectItem>
<SelectItem value="table-all">
<div className="flex items-center space-x-2">
<Table className="h-4 w-4" />
<span> 🆕</span>
</div>
</SelectItem>
<SelectItem value="flow-selection">
<div className="flex items-center space-x-2">
<Workflow className="h-4 w-4" />
<span> </span>
</div>
</SelectItem>
<SelectItem value="flow-step-all">
<div className="flex items-center space-x-2">
<Workflow className="h-4 w-4" />
<span> 🆕</span>
</div>
</SelectItem>
<SelectItem value="both">
@ -269,13 +287,22 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
<span> + </span>
</div>
</SelectItem>
<SelectItem value="all-sources">
<div className="flex items-center space-x-2">
<Layers className="h-4 w-4" />
<span> 🆕</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{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 && "폼 데이터를 기본으로 사용합니다"}
</p>
</div>

View File

@ -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<string[]>([]);
const [stepDataLoading, setStepDataLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [movingData, setMovingData] = useState(false);
const [selectedNextStepId, setSelectedNextStepId] = useState<number | null>(null); // 선택된 다음 단계
// 오딧 로그 상태
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
@ -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<number, number> = {};
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 && (
<div className="bg-muted/30 mt-4 w-full rounded-lg p-4 sm:mt-6 sm:rounded-xl sm:p-5 lg:mt-8 lg:p-6">
{/* 헤더 */}
<div className="mb-4 flex flex-col items-start justify-between gap-3 sm:mb-6 sm:flex-row sm:items-center">
<div className="mb-4 sm:mb-6">
<div className="flex-1">
<h4 className="text-foreground text-base font-semibold sm:text-lg">
{steps.find((s) => s.id === selectedStepId)?.stepName}
</h4>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"> {stepData.length} </p>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
{stepData.length}
{selectedRows.size > 0 && (
<span className="text-primary ml-2 font-medium">({selectedRows.size} )</span>
)}
</p>
</div>
{allowDataMove &&
selectedRows.size > 0 &&
(() => {
const nextSteps = getNextSteps(selectedStepId);
return nextSteps.length > 1 ? (
// 다음 단계가 여러 개인 경우: 선택 UI 표시
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<Select
value={selectedNextStepId?.toString() || ""}
onValueChange={(value) => setSelectedNextStepId(Number(value))}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:w-[180px] sm:text-sm">
<SelectValue placeholder="이동할 단계 선택" />
</SelectTrigger>
<SelectContent>
{nextSteps.map((step) => (
<SelectItem key={step.id} value={step.id.toString()}>
{step.stepName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => handleMoveToNext()}
disabled={movingData || !selectedNextStepId}
className="h-8 gap-1 px-3 text-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm"
>
{movingData ? (
<>
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
<span> ...</span>
</>
) : (
<>
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
<span> ({selectedRows.size})</span>
</>
)}
</Button>
</div>
) : (
// 다음 단계가 하나인 경우: 바로 이동 버튼만 표시
<Button
onClick={() => handleMoveToNext()}
disabled={movingData}
className="h-8 w-full gap-1 px-3 text-xs sm:h-10 sm:w-auto sm:gap-2 sm:px-4 sm:text-sm"
>
{movingData ? (
<>
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
<span className="hidden sm:inline"> ...</span>
<span className="sm:hidden"></span>
</>
) : (
<>
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
<span className="hidden sm:inline">
{nextSteps.length > 0 ? `${nextSteps[0].stepName}(으)로 이동` : "다음 단계로 이동"} (
{selectedRows.size})
</span>
<span className="sm:hidden"> ({selectedRows.size})</span>
</>
)}
</Button>
);
})()}
</div>
{/* 데이터 테이블 */}

View File

@ -204,21 +204,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
// 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<DynamicComponentRendererProps> =
};
// 렌더러가 클래스인지 함수인지 확인
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 <NewComponentRenderer {...rendererProps} />;
}
}

View File

@ -73,19 +73,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
if (currentStep === null) {
// 🔧 화이트리스트 모드일 때는 단계 미선택 시 숨김
if (flowConfig.mode === "whitelist") {
console.log("🔍 [ButtonPrimary] 화이트리스트 모드 + 단계 미선택 → 숨김");
return false;
}
// 블랙리스트나 all 모드는 표시
@ -119,18 +105,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
useEffect(() => {
return () => {
if (currentLoadingToastRef.current !== undefined) {
console.log("🧹 컴포넌트 언마운트 시 토스트 정리");
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
}
@ -240,21 +213,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
};
}
// 디버그 로그 (필요시 주석 해제)
// 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<ButtonPrimaryComponentProps> = ({
// 실제 액션 실행 함수
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<ButtonPrimaryComponentProps> = ({
// 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<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
// 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<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
// 이벤트 핸들러
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<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
});
setShowConfirmDialog(true);
} else {
console.log("🚀 액션 바로 실행 중...");
// 확인이 필요하지 않은 액션은 바로 실행
await executeAction(processedConfig.action, context);
}
} else {
console.log("⚠️ 액션 실행 조건 불만족:", {
isInteractive,
hasAction: !!processedConfig.action,
: !isInteractive ? "인터랙티브 모드 아님" : "액션 없음",
});
// 액션이 설정되지 않은 경우 기본 onClick 실행
onClick?.();
}

View File

@ -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;

View File

@ -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();
}

View File

@ -15,7 +15,18 @@ export interface ButtonExecutionContext {
formData: Record<string, any>;
selectedRows?: any[];
selectedRowsData?: Record<string, any>[];
controlDataSource?: "form" | "table-selection" | "both";
controlDataSource?: "form" | "table-selection" | "table-all" | "flow-selection" | "flow-step-all" | "both" | "all-sources";
// 🆕 테이블 전체 데이터 (table-all 모드용)
tableAllData?: Record<string, any>[];
// 🆕 플로우 스텝 전체 데이터 (flow-step-all 모드용)
flowStepAllData?: Record<string, any>[];
flowStepId?: number;
// 🆕 플로우 선택 데이터 (flow-selection 모드용)
flowSelectedData?: Record<string, any>[];
onRefresh?: () => void;
onClose?: () => void;
}
@ -141,15 +152,134 @@ export async function executeButtonWithFlow(
*
*/
function prepareContextData(context: ButtonExecutionContext): Record<string, any> {
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 || {}],
};
}
}
/**

View File

@ -49,47 +49,24 @@ export const useFlowStepStore = create<FlowStepState>()(
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 };

View File

@ -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";
/**
*

View File

@ -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` 모드 지원

View File

@ -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` 처리 추가
- 노드 설정이 올바르게 반영되도록 수정
- 일관성 있는 데이터 흐름 확립

View File

@ -0,0 +1,203 @@
# 속성 패널 스크롤 문제 해결 가이드
## 적용된 수정사항
### 1. PropertiesPanel.tsx
```tsx
// 최상위 컨테이너
<div className="flex h-full w-full flex-col">
// 헤더 (고정 높이)
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-white p-4">
// 스크롤 영역 (중요!)
<div
className="flex-1 overflow-y-scroll bg-gray-50"
style={{
maxHeight: 'calc(100vh - 64px)',
overflowY: 'scroll',
WebkitOverflowScrolling: 'touch'
}}
>
```
### 2. FlowEditor.tsx
```tsx
// 속성 패널 컨테이너 단순화
<div className="h-full w-[350px] border-l bg-white">
<PropertiesPanel />
</div>
```
### 3. TableSourceProperties.tsx / ExternalDBSourceProperties.tsx
```tsx
// ScrollArea 제거, 일반 div 사용
<div className="min-h-full space-y-4 p-4">
{/* 컨텐츠 */}
</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
<!-- 올바른 구조 -->
<div class="flex h-full w-full flex-col"> <!-- PropertiesPanel -->
<div class="flex h-16 shrink-0..."> <!-- 헤더 -->
<div class="flex-1 overflow-y-scroll..."> <!-- 스크롤 영역 -->
<div class="min-h-full space-y-4 p-4"> <!-- 속성 컴포넌트 -->
<!-- 긴 컨텐츠 -->
</div>
</div>
</div>
```
**단계 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)
<div className="rounded bg-yellow-50 p-2 text-xs text-yellow-700">
📏 스크롤 테스트: 이 패널은 스크롤 가능해야 합니다
</div>
// 제거할 부분 2 (줄 340-357)
<div className="space-y-2">
<div className="rounded bg-red-50 p-4 text-red-700">
{/* ... */}
</div>
{[...Array(20)].map((_, i) => (/* ... */))}
<div className="rounded bg-blue-50 p-4 text-blue-700">
{/* ... */}
</div>
</div>
```
### 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 키 또는 방향키
하나라도 작동하면 성공입니다!

View File

@ -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
<OptimizedButtonComponent
component={buttonComponent}
selectedRowsData={selectedRowsData}
// 테이블 전체 데이터 로드 콜백
onRequestTableAllData={async () => {
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<any[]>([]);
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 확인
<OptimizedButtonComponent
onRequestTableAllData={async () => {
// 이 함수가 구현되어 있어야 함
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` 데이터 소스 추가
- 지연 로딩 메커니즘 구현