제어관리 개선판
This commit is contained in:
parent
96252270d7
commit
8d1f0e7098
|
|
@ -677,13 +677,42 @@ 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}, 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.info(`🔌 외부 DB 소스 조회: ${connectionId}.${tableName}`);
|
||||
logger.warn(
|
||||
`⚠️ context-data 모드이지만 전달된 데이터가 없습니다. 빈 배열 반환.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. table-all 모드: 외부 DB 테이블 전체 데이터 조회
|
||||
if (nodeDataSourceType === "table-all") {
|
||||
if (!connectionId || !tableName) {
|
||||
throw new Error(
|
||||
"외부 DB 연결 정보 또는 테이블명이 설정되지 않았습니다."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 연결 풀 서비스 임포트 (동적 임포트로 순환 참조 방지)
|
||||
|
|
@ -709,10 +738,14 @@ export class NodeFlowExecutionService {
|
|||
logger.info(`📊 외부 DB 쿼리 실행: ${sql}`);
|
||||
|
||||
// 연결 풀을 통해 쿼리 실행
|
||||
const result = await poolService.executeQuery(connectionId, sql, params);
|
||||
const result = await poolService.executeQuery(
|
||||
connectionId,
|
||||
sql,
|
||||
params
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`✅ 외부 DB 소스 조회 완료: ${tableName}, ${result.length}건`
|
||||
`✅ 외부 DB 전체 데이터 조회 완료: ${tableName}, ${result.length}건`
|
||||
);
|
||||
|
||||
return result;
|
||||
|
|
@ -724,6 +757,22 @@ export class NodeFlowExecutionService {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. 알 수 없는 모드 (기본값으로 처리)
|
||||
logger.warn(
|
||||
`⚠️ 알 수 없는 dataSourceType: ${nodeDataSourceType}, context-data로 처리`
|
||||
);
|
||||
|
||||
if (
|
||||
context.sourceData &&
|
||||
Array.isArray(context.sourceData) &&
|
||||
context.sourceData.length > 0
|
||||
) {
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드 실행
|
||||
*/
|
||||
|
|
@ -731,28 +780,41 @@ 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}건`
|
||||
`📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건`
|
||||
);
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
// 외부 데이터가 없으면 DB 쿼리 실행
|
||||
const { tableName, schema, whereConditions } = node.data;
|
||||
|
||||
if (!tableName) {
|
||||
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)
|
||||
|
|
@ -762,11 +824,29 @@ export class NodeFlowExecutionService {
|
|||
|
||||
const result = await query(sql, whereResult.values);
|
||||
|
||||
logger.info(`📊 테이블 소스 조회: ${tableName}, ${result.length}건`);
|
||||
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
|
||||
) {
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT 액션 노드 실행
|
||||
*/
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ export default function ScreenViewPage() {
|
|||
<DynamicComponentRenderer
|
||||
component={relativeButton}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onDataflowComplete={() => {}}
|
||||
screenId={screenId}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
</Button>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,30 +831,91 @@ 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";
|
||||
|
||||
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;
|
||||
dataSourceType = "flow-selection";
|
||||
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("📊 테이블 선택 데이터 사용:", sourceData);
|
||||
console.log("📊 [자동] 테이블 선택 데이터 사용");
|
||||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
// 폼 데이터 사용 (배열로 감싸서 일관성 유지)
|
||||
sourceData = [context.formData];
|
||||
dataSourceType = "form";
|
||||
console.log("📝 폼 데이터 사용:", sourceData);
|
||||
console.log("📝 [자동] 폼 데이터 사용");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("📦 최종 전달 데이터:", {
|
||||
dataSourceType,
|
||||
sourceDataCount: sourceData ? (Array.isArray(sourceData) ? sourceData.length : 1) : 0,
|
||||
sourceData,
|
||||
});
|
||||
|
||||
const result = await executeNodeFlow(flowId, {
|
||||
dataSourceType,
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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 || [],
|
||||
controlDataSource: context.controlDataSource || "form",
|
||||
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 || {}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
* 직접 제어 설정
|
||||
|
|
|
|||
|
|
@ -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` 모드 지원
|
||||
|
||||
|
|
@ -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` 처리 추가
|
||||
- 노드 설정이 올바르게 반영되도록 수정
|
||||
- 일관성 있는 데이터 흐름 확립
|
||||
|
||||
|
|
@ -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 키 또는 방향키
|
||||
|
||||
하나라도 작동하면 성공입니다!
|
||||
|
||||
|
|
@ -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` 데이터 소스 추가
|
||||
- 지연 로딩 메커니즘 구현
|
||||
|
||||
Loading…
Reference in New Issue