수식 노드 구현
This commit is contained in:
@@ -25,6 +25,7 @@ import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||
import { AggregateNode } from "./nodes/AggregateNode";
|
||||
import { FormulaTransformNode } from "./nodes/FormulaTransformNode";
|
||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
@@ -44,6 +45,7 @@ const nodeTypes = {
|
||||
condition: ConditionNode,
|
||||
dataTransform: DataTransformNode,
|
||||
aggregate: AggregateNode,
|
||||
formulaTransform: FormulaTransformNode,
|
||||
// 데이터 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수식 변환 노드 (Formula Transform Node)
|
||||
* 산술 연산, 함수, 조건문 등을 사용해 새로운 필드를 계산합니다.
|
||||
* 타겟 테이블의 기존 값을 참조하여 UPSERT 시나리오를 지원합니다.
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Database, ArrowRight } from "lucide-react";
|
||||
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
|
||||
|
||||
// 수식 타입별 라벨
|
||||
const FORMULA_TYPE_LABELS: Record<FormulaType, { label: string; color: string }> = {
|
||||
arithmetic: { label: "산술", color: "bg-orange-500" },
|
||||
function: { label: "함수", color: "bg-blue-500" },
|
||||
condition: { label: "조건", color: "bg-yellow-500" },
|
||||
static: { label: "정적", color: "bg-gray-500" },
|
||||
};
|
||||
|
||||
// 연산자 표시
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
"+": "+",
|
||||
"-": "-",
|
||||
"*": "x",
|
||||
"/": "/",
|
||||
"%": "%",
|
||||
};
|
||||
|
||||
// 수식 요약 생성
|
||||
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
||||
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
||||
|
||||
switch (formulaType) {
|
||||
case "arithmetic": {
|
||||
if (!arithmetic) return "미설정";
|
||||
const left = arithmetic.leftOperand;
|
||||
const right = arithmetic.rightOperand;
|
||||
const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
|
||||
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
|
||||
return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||
}
|
||||
case "function": {
|
||||
if (!func) return "미설정";
|
||||
const args = func.arguments
|
||||
.map((arg) => (arg.type === "static" ? arg.value : `${arg.type}.${arg.field || arg.resultField}`))
|
||||
.join(", ");
|
||||
return `${func.name}(${args})`;
|
||||
}
|
||||
case "condition": {
|
||||
if (!condition) return "미설정";
|
||||
return "CASE WHEN ... THEN ... ELSE ...";
|
||||
}
|
||||
case "static": {
|
||||
return staticValue !== undefined ? String(staticValue) : "미설정";
|
||||
}
|
||||
default:
|
||||
return "미설정";
|
||||
}
|
||||
}
|
||||
|
||||
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<FormulaTransformNodeData>) => {
|
||||
const transformationCount = data.transformations?.length || 0;
|
||||
const hasTargetLookup = !!data.targetLookup?.tableName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[300px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-orange-500 px-3 py-2 text-white">
|
||||
<Calculator className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "수식 변환"}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{transformationCount}개 변환 {hasTargetLookup && "| 타겟 조회"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-3 p-3">
|
||||
{/* 타겟 테이블 조회 설정 */}
|
||||
{hasTargetLookup && (
|
||||
<div className="rounded bg-blue-50 p-2">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Database className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-xs font-medium text-blue-700">타겟 조회</span>
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">{data.targetLookup?.tableLabel || data.targetLookup?.tableName}</div>
|
||||
{data.targetLookup?.lookupKeys && data.targetLookup.lookupKeys.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.targetLookup.lookupKeys.slice(0, 2).map((key, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700"
|
||||
>
|
||||
{key.sourceFieldLabel || key.sourceField}
|
||||
<ArrowRight className="h-2 w-2" />
|
||||
{key.targetFieldLabel || key.targetField}
|
||||
</span>
|
||||
))}
|
||||
{data.targetLookup.lookupKeys.length > 2 && (
|
||||
<span className="text-xs text-blue-500">+{data.targetLookup.lookupKeys.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 변환 규칙들 */}
|
||||
{transformationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 4).map((trans, idx) => {
|
||||
const typeInfo = FORMULA_TYPE_LABELS[trans.formulaType];
|
||||
return (
|
||||
<div key={trans.id || idx} className="rounded bg-gray-50 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium text-white ${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{trans.outputFieldLabel || trans.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-gray-500">{getFormulaSummary(trans)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 4 && (
|
||||
<div className="text-center text-xs text-gray-400">... 외 {data.transformations.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-400">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-orange-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-orange-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FormulaTransformNode.displayName = "FormulaTransformNode";
|
||||
@@ -16,6 +16,7 @@ import { ExternalDBSourceProperties } from "./properties/ExternalDBSourcePropert
|
||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||
import { AggregateProperties } from "./properties/AggregateProperties";
|
||||
import { FormulaTransformProperties } from "./properties/FormulaTransformProperties";
|
||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||
import { CommentProperties } from "./properties/CommentProperties";
|
||||
import { LogProperties } from "./properties/LogProperties";
|
||||
@@ -31,21 +32,21 @@ export function PropertiesPanel() {
|
||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
}}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: "64px",
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
<div>
|
||||
@@ -60,12 +61,12 @@ export function PropertiesPanel() {
|
||||
</div>
|
||||
|
||||
{/* 내용 - 스크롤 가능 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
{selectedNodes.length === 0 ? (
|
||||
@@ -125,6 +126,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
||||
case "aggregate":
|
||||
return <AggregateProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "formulaTransform":
|
||||
return <FormulaTransformProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "restAPISource":
|
||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
@@ -173,6 +177,7 @@ function getNodeTypeLabel(type: NodeType): string {
|
||||
fieldMapping: "필드 매핑",
|
||||
dataTransform: "데이터 변환",
|
||||
aggregate: "집계",
|
||||
formulaTransform: "수식 변환",
|
||||
insertAction: "INSERT 액션",
|
||||
updateAction: "UPDATE 액션",
|
||||
deleteAction: "DELETE 액션",
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수식 변환 노드 속성 편집 패널
|
||||
* - 타겟 테이블 조회 설정 (기존 값 참조용)
|
||||
* - 산술 연산, 함수, 조건, 정적 값 변환 규칙 설정
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Calculator, Database, ArrowRight, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
|
||||
|
||||
interface FormulaTransformPropertiesProps {
|
||||
nodeId: string;
|
||||
data: FormulaTransformNodeData;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
// 수식 타입 옵션
|
||||
const FORMULA_TYPES: Array<{ value: FormulaType; label: string; description: string }> = [
|
||||
{ value: "arithmetic", label: "산술 연산", description: "덧셈, 뺄셈, 곱셈, 나눗셈" },
|
||||
{ value: "function", label: "함수", description: "NOW, COALESCE, CONCAT 등" },
|
||||
{ value: "condition", label: "조건", description: "CASE WHEN ... THEN ... ELSE" },
|
||||
{ value: "static", label: "정적 값", description: "고정 값 설정" },
|
||||
];
|
||||
|
||||
// 산술 연산자
|
||||
const ARITHMETIC_OPERATORS = [
|
||||
{ value: "+", label: "더하기 (+)" },
|
||||
{ value: "-", label: "빼기 (-)" },
|
||||
{ value: "*", label: "곱하기 (*)" },
|
||||
{ value: "/", label: "나누기 (/)" },
|
||||
{ value: "%", label: "나머지 (%)" },
|
||||
];
|
||||
|
||||
// 함수 목록
|
||||
const FUNCTIONS = [
|
||||
{ value: "NOW", label: "NOW()", description: "현재 시간", argCount: 0 },
|
||||
{ value: "COALESCE", label: "COALESCE(a, b)", description: "NULL이면 대체값 사용", argCount: 2 },
|
||||
{ value: "CONCAT", label: "CONCAT(a, b, ...)", description: "문자열 연결", argCount: -1 },
|
||||
{ value: "UPPER", label: "UPPER(text)", description: "대문자 변환", argCount: 1 },
|
||||
{ value: "LOWER", label: "LOWER(text)", description: "소문자 변환", argCount: 1 },
|
||||
{ value: "TRIM", label: "TRIM(text)", description: "공백 제거", argCount: 1 },
|
||||
{ value: "ROUND", label: "ROUND(number)", description: "반올림", argCount: 1 },
|
||||
{ value: "ABS", label: "ABS(number)", description: "절대값", argCount: 1 },
|
||||
];
|
||||
|
||||
// 조건 연산자
|
||||
const CONDITION_OPERATORS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "보다 큼 (>)" },
|
||||
{ value: "<", label: "보다 작음 (<)" },
|
||||
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||
{ value: "IS_NULL", label: "NULL임" },
|
||||
{ value: "IS_NOT_NULL", label: "NULL 아님" },
|
||||
];
|
||||
|
||||
export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
// 로컬 상태
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "수식 변환");
|
||||
const [targetLookup, setTargetLookup] = useState(data.targetLookup);
|
||||
const [transformations, setTransformations] = useState(data.transformations || []);
|
||||
|
||||
// 테이블/컬럼 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "수식 변환");
|
||||
setTargetLookup(data.targetLookup);
|
||||
setTransformations(data.transformations || []);
|
||||
}, [data]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 타겟 테이블 변경 시 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetLookup?.tableName) {
|
||||
loadTargetColumns(targetLookup.tableName);
|
||||
}
|
||||
}, [targetLookup?.tableName]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로)
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (
|
||||
targetNodeId: string,
|
||||
visitedNodes = new Set<string>(),
|
||||
): Array<{ name: string; label?: string }> => {
|
||||
if (visitedNodes.has(targetNodeId)) return [];
|
||||
visitedNodes.add(targetNodeId);
|
||||
|
||||
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string }> = [];
|
||||
|
||||
sourceNodes.forEach((node) => {
|
||||
// 테이블/외부DB 소스 노드
|
||||
if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
if (nodeFields && Array.isArray(nodeFields)) {
|
||||
nodeFields.forEach((field: any) => {
|
||||
const fieldName = field.name || field.fieldName || field.column_name;
|
||||
const fieldLabel = field.label || field.displayName || field.label_ko;
|
||||
if (fieldName) {
|
||||
fields.push({ name: fieldName, label: fieldLabel });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 데이터 변환 노드
|
||||
else if (node.type === "dataTransform") {
|
||||
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperFields);
|
||||
|
||||
// 변환된 필드 추가
|
||||
if ((node.data as any).transformations) {
|
||||
(node.data as any).transformations.forEach((transform: any) => {
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
if (targetField) {
|
||||
fields.push({
|
||||
name: targetField,
|
||||
label: transform.targetFieldLabel || targetField,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 집계 노드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
// 그룹 기준 필드
|
||||
if (nodeData.groupByFields) {
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({ name: fieldName, label: groupField.fieldLabel || fieldName });
|
||||
}
|
||||
});
|
||||
}
|
||||
// 집계 결과 필드
|
||||
const aggregations = nodeData.aggregations || [];
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
if (outputFieldName) {
|
||||
fields.push({ name: outputFieldName, label: aggFunc.outputFieldLabel || outputFieldName });
|
||||
}
|
||||
});
|
||||
}
|
||||
// 기타 노드: 상위 탐색
|
||||
else {
|
||||
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperFields);
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
const fields = getAllSourceFields(nodeId);
|
||||
const uniqueFields = Array.from(new Map(fields.map((field) => [field.name, field])).values());
|
||||
setSourceFields(uniqueFields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 저장 함수
|
||||
const saveToNode = useCallback(
|
||||
(updates: Partial<FormulaTransformNodeData>) => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetLookup,
|
||||
transformations,
|
||||
...updates,
|
||||
});
|
||||
},
|
||||
[nodeId, updateNode, displayName, targetLookup, transformations],
|
||||
);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
const options: TableOption[] = tableList.map((table) => ({
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
label: (table as any).tableLabel || table.displayName || table.tableName,
|
||||
}));
|
||||
setTables(options);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 타겟 테이블 컬럼 로딩
|
||||
const loadTargetColumns = async (tableName: string) => {
|
||||
try {
|
||||
setTargetColumnsLoading(true);
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
}));
|
||||
setTargetColumns(columnInfo);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로딩 실패:", error);
|
||||
setTargetColumns([]);
|
||||
} finally {
|
||||
setTargetColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 타겟 테이블 선택
|
||||
const handleTargetTableSelect = async (tableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === tableName);
|
||||
const newTargetLookup = {
|
||||
tableName,
|
||||
tableLabel: selectedTable?.label,
|
||||
lookupKeys: targetLookup?.lookupKeys || [],
|
||||
};
|
||||
setTargetLookup(newTargetLookup);
|
||||
saveToNode({ targetLookup: newTargetLookup });
|
||||
setTablesOpen(false);
|
||||
};
|
||||
|
||||
// 타겟 테이블 조회 키 추가
|
||||
const handleAddLookupKey = () => {
|
||||
const newLookupKeys = [...(targetLookup?.lookupKeys || []), { sourceField: "", targetField: "" }];
|
||||
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
|
||||
setTargetLookup(newTargetLookup);
|
||||
saveToNode({ targetLookup: newTargetLookup });
|
||||
};
|
||||
|
||||
// 타겟 테이블 조회 키 삭제
|
||||
const handleRemoveLookupKey = (index: number) => {
|
||||
const newLookupKeys = (targetLookup?.lookupKeys || []).filter((_, i) => i !== index);
|
||||
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
|
||||
setTargetLookup(newTargetLookup);
|
||||
saveToNode({ targetLookup: newTargetLookup });
|
||||
};
|
||||
|
||||
// 타겟 테이블 조회 키 변경
|
||||
const handleLookupKeyChange = (index: number, field: string, value: string) => {
|
||||
const newLookupKeys = [...(targetLookup?.lookupKeys || [])];
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newLookupKeys[index] = {
|
||||
...newLookupKeys[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
const targetCol = targetColumns.find((c) => c.columnName === value);
|
||||
newLookupKeys[index] = {
|
||||
...newLookupKeys[index],
|
||||
targetField: value,
|
||||
targetFieldLabel: targetCol?.columnLabel,
|
||||
};
|
||||
}
|
||||
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
|
||||
setTargetLookup(newTargetLookup);
|
||||
saveToNode({ targetLookup: newTargetLookup });
|
||||
};
|
||||
|
||||
// 변환 규칙 추가
|
||||
const handleAddTransformation = () => {
|
||||
const newTransformation = {
|
||||
id: `trans_${Date.now()}`,
|
||||
outputField: "",
|
||||
outputFieldLabel: "",
|
||||
formulaType: "arithmetic" as FormulaType,
|
||||
arithmetic: {
|
||||
leftOperand: { type: "source" as const, field: "" },
|
||||
operator: "+" as const,
|
||||
rightOperand: { type: "source" as const, field: "" },
|
||||
},
|
||||
};
|
||||
const newTransformations = [...transformations, newTransformation];
|
||||
setTransformations(newTransformations);
|
||||
saveToNode({ transformations: newTransformations });
|
||||
};
|
||||
|
||||
// 변환 규칙 삭제
|
||||
const handleRemoveTransformation = (index: number) => {
|
||||
const newTransformations = transformations.filter((_, i) => i !== index);
|
||||
setTransformations(newTransformations);
|
||||
saveToNode({ transformations: newTransformations });
|
||||
};
|
||||
|
||||
// 변환 규칙 변경
|
||||
const handleTransformationChange = (
|
||||
index: number,
|
||||
updates: Partial<FormulaTransformNodeData["transformations"][0]>,
|
||||
) => {
|
||||
const newTransformations = [...transformations];
|
||||
newTransformations[index] = { ...newTransformations[index], ...updates };
|
||||
setTransformations(newTransformations);
|
||||
saveToNode({ transformations: newTransformations });
|
||||
};
|
||||
|
||||
// 수식 타입 변경
|
||||
const handleFormulaTypeChange = (index: number, newType: FormulaType) => {
|
||||
const newTransformations = [...transformations];
|
||||
const trans = newTransformations[index];
|
||||
|
||||
// 기본값 설정
|
||||
switch (newType) {
|
||||
case "arithmetic":
|
||||
trans.arithmetic = {
|
||||
leftOperand: { type: "source", field: "" },
|
||||
operator: "+",
|
||||
rightOperand: { type: "source", field: "" },
|
||||
};
|
||||
trans.function = undefined;
|
||||
trans.condition = undefined;
|
||||
trans.staticValue = undefined;
|
||||
break;
|
||||
case "function":
|
||||
trans.function = {
|
||||
name: "COALESCE",
|
||||
arguments: [
|
||||
{ type: "source", field: "" },
|
||||
{ type: "static", value: 0 },
|
||||
],
|
||||
};
|
||||
trans.arithmetic = undefined;
|
||||
trans.condition = undefined;
|
||||
trans.staticValue = undefined;
|
||||
break;
|
||||
case "condition":
|
||||
trans.condition = {
|
||||
when: {
|
||||
leftOperand: { type: "source", field: "" },
|
||||
operator: "=",
|
||||
rightOperand: { type: "static", value: "" },
|
||||
},
|
||||
then: { type: "static", value: "" },
|
||||
else: { type: "static", value: "" },
|
||||
};
|
||||
trans.arithmetic = undefined;
|
||||
trans.function = undefined;
|
||||
trans.staticValue = undefined;
|
||||
break;
|
||||
case "static":
|
||||
trans.staticValue = "";
|
||||
trans.arithmetic = undefined;
|
||||
trans.function = undefined;
|
||||
trans.condition = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
trans.formulaType = newType;
|
||||
setTransformations(newTransformations);
|
||||
saveToNode({ transformations: newTransformations });
|
||||
};
|
||||
|
||||
// 이전 변환 결과 필드 목록 (result 타입용)
|
||||
const getResultFields = (currentIndex: number) => {
|
||||
return transformations
|
||||
.slice(0, currentIndex)
|
||||
.filter((t) => t.outputField)
|
||||
.map((t) => ({
|
||||
name: t.outputField,
|
||||
label: t.outputFieldLabel || t.outputField,
|
||||
}));
|
||||
};
|
||||
|
||||
// 피연산자 렌더링 (산술, 함수, 조건에서 공통 사용)
|
||||
const renderOperandSelector = (
|
||||
operand: { type: string; field?: string; fieldLabel?: string; value?: string | number; resultField?: string },
|
||||
onChange: (updates: any) => void,
|
||||
currentTransIndex: number,
|
||||
) => {
|
||||
const resultFields = getResultFields(currentTransIndex);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={operand.type}
|
||||
onValueChange={(value) => onChange({ type: value, field: "", value: undefined, resultField: "" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="source">소스 필드 (source.*)</SelectItem>
|
||||
{targetLookup?.tableName && <SelectItem value="target">타겟 필드 (target.*)</SelectItem>}
|
||||
<SelectItem value="static">정적 값</SelectItem>
|
||||
{resultFields.length > 0 && <SelectItem value="result">이전 결과 (result.*)</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{operand.type === "source" && (
|
||||
<Select
|
||||
value={operand.field || ""}
|
||||
onValueChange={(value) => {
|
||||
const sf = sourceFields.find((f) => f.name === value);
|
||||
onChange({ ...operand, field: value, fieldLabel: sf?.label });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 없음</div>
|
||||
) : (
|
||||
sourceFields.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name}>
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{operand.type === "target" && (
|
||||
<Select
|
||||
value={operand.field || ""}
|
||||
onValueChange={(value) => {
|
||||
const tc = targetColumns.find((c) => c.columnName === value);
|
||||
onChange({ ...operand, field: value, fieldLabel: tc?.columnLabel });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">타겟 테이블 선택 필요</div>
|
||||
) : (
|
||||
targetColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.columnLabel || c.columnName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{operand.type === "static" && (
|
||||
<Input
|
||||
value={operand.value ?? ""}
|
||||
onChange={(e) => onChange({ ...operand, value: e.target.value })}
|
||||
placeholder="값 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{operand.type === "result" && (
|
||||
<Select
|
||||
value={operand.resultField || ""}
|
||||
onValueChange={(value) => {
|
||||
const rf = resultFields.find((f) => f.name === value);
|
||||
onChange({ ...operand, resultField: value, fieldLabel: rf?.label });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="이전 결과 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resultFields.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name}>
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-orange-50 p-2">
|
||||
<Calculator className="h-4 w-4 text-orange-600" />
|
||||
<span className="font-semibold text-orange-600">수식 변환 노드</span>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
saveToNode({ displayName: e.target.value });
|
||||
}}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타겟 테이블 조회 설정 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<h3 className="text-sm font-semibold">타겟 테이블 조회 (선택)</h3>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">
|
||||
UPSERT 시 기존 값을 참조하려면 타겟 테이블을 설정하세요. 수식에서 target.* 로 참조 가능합니다.
|
||||
</p>
|
||||
|
||||
{/* 타겟 테이블 선택 */}
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mb-3 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetLookup?.tableName ? (
|
||||
<span>{targetLookup.tableLabel || targetLookup.tableName}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">타겟 테이블 선택 (선택사항)</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTargetTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetLookup?.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 조회 키 설정 */}
|
||||
{targetLookup?.tableName && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">조회 키 (source → target)</Label>
|
||||
<Button size="sm" variant="outline" onClick={handleAddLookupKey} className="h-6 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(targetLookup.lookupKeys || []).length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
조회 키를 추가하세요 (예: item_code, lot_number)
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{targetLookup.lookupKeys.map((key, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 rounded border bg-gray-50 p-2">
|
||||
<Select
|
||||
value={key.sourceField}
|
||||
onValueChange={(v) => handleLookupKeyChange(idx, "sourceField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder="소스 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name}>
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
|
||||
<Select
|
||||
value={key.targetField}
|
||||
onValueChange={(v) => handleLookupKeyChange(idx, "targetField", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder="타겟 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.columnLabel || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveLookupKey(idx)}
|
||||
className="h-6 w-6 p-0 text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 변환 규칙 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-orange-600" />
|
||||
<h3 className="text-sm font-semibold">변환 규칙</h3>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{transformations.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
||||
변환 규칙을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{transformations.map((trans, index) => (
|
||||
<div key={trans.id || index} className="rounded border bg-orange-50 p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-orange-700">변환 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveTransformation(index)}
|
||||
className="h-6 w-6 p-0 text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 출력 필드명 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드명</Label>
|
||||
<Input
|
||||
value={trans.outputField || ""}
|
||||
onChange={(e) => handleTransformationChange(index, { outputField: e.target.value })}
|
||||
placeholder="예: new_current_qty"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드 라벨 (선택)</Label>
|
||||
<Input
|
||||
value={trans.outputFieldLabel || ""}
|
||||
onChange={(e) => handleTransformationChange(index, { outputFieldLabel: e.target.value })}
|
||||
placeholder="예: 새 현재고량"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수식 타입 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">수식 타입</Label>
|
||||
<Select
|
||||
value={trans.formulaType}
|
||||
onValueChange={(value) => handleFormulaTypeChange(index, value as FormulaType)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FORMULA_TYPES.map((ft) => (
|
||||
<SelectItem key={ft.value} value={ft.value}>
|
||||
<div>
|
||||
<div className="font-medium">{ft.label}</div>
|
||||
<div className="text-xs text-gray-400">{ft.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 수식 타입별 설정 */}
|
||||
{trans.formulaType === "arithmetic" && trans.arithmetic && (
|
||||
<div className="space-y-2 rounded border bg-white p-2">
|
||||
<Label className="text-xs text-gray-600">산술 연산</Label>
|
||||
|
||||
{/* 좌측 피연산자 */}
|
||||
<div className="rounded bg-gray-50 p-2">
|
||||
<div className="mb-1 text-xs text-gray-500">좌측</div>
|
||||
{renderOperandSelector(
|
||||
trans.arithmetic.leftOperand,
|
||||
(updates) => {
|
||||
const newArithmetic = { ...trans.arithmetic!, leftOperand: updates };
|
||||
handleTransformationChange(index, { arithmetic: newArithmetic });
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연산자 */}
|
||||
<Select
|
||||
value={trans.arithmetic.operator}
|
||||
onValueChange={(value) => {
|
||||
const newArithmetic = { ...trans.arithmetic!, operator: value as any };
|
||||
handleTransformationChange(index, { arithmetic: newArithmetic });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ARITHMETIC_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 우측 피연산자 */}
|
||||
<div className="rounded bg-gray-50 p-2">
|
||||
<div className="mb-1 text-xs text-gray-500">우측</div>
|
||||
{renderOperandSelector(
|
||||
trans.arithmetic.rightOperand,
|
||||
(updates) => {
|
||||
const newArithmetic = { ...trans.arithmetic!, rightOperand: updates };
|
||||
handleTransformationChange(index, { arithmetic: newArithmetic });
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trans.formulaType === "function" && trans.function && (
|
||||
<div className="space-y-2 rounded border bg-white p-2">
|
||||
<Label className="text-xs text-gray-600">함수</Label>
|
||||
|
||||
<Select
|
||||
value={trans.function.name}
|
||||
onValueChange={(value) => {
|
||||
const funcDef = FUNCTIONS.find((f) => f.value === value);
|
||||
const argCount = funcDef?.argCount || 0;
|
||||
const newArgs =
|
||||
argCount === 0
|
||||
? []
|
||||
: Array(argCount === -1 ? 2 : argCount)
|
||||
.fill(null)
|
||||
.map(() => ({ type: "source" as const, field: "" }));
|
||||
handleTransformationChange(index, {
|
||||
function: { name: value as any, arguments: newArgs },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FUNCTIONS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
<div>
|
||||
<div className="font-mono font-medium">{f.label}</div>
|
||||
<div className="text-xs text-gray-400">{f.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 함수 인자들 */}
|
||||
{trans.function.arguments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{trans.function.arguments.map((arg, argIdx) => (
|
||||
<div key={argIdx} className="rounded bg-gray-50 p-2">
|
||||
<div className="mb-1 text-xs text-gray-500">인자 {argIdx + 1}</div>
|
||||
{renderOperandSelector(
|
||||
arg,
|
||||
(updates) => {
|
||||
const newArgs = [...trans.function!.arguments];
|
||||
newArgs[argIdx] = updates;
|
||||
handleTransformationChange(index, {
|
||||
function: { ...trans.function!, arguments: newArgs },
|
||||
});
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trans.formulaType === "condition" && trans.condition && (
|
||||
<div className="space-y-2 rounded border bg-white p-2">
|
||||
<Label className="text-xs text-gray-600">조건 (CASE WHEN)</Label>
|
||||
|
||||
{/* WHEN 절 */}
|
||||
<div className="rounded bg-yellow-50 p-2">
|
||||
<div className="mb-1 text-xs font-medium text-yellow-700">WHEN</div>
|
||||
<div className="space-y-2">
|
||||
{renderOperandSelector(
|
||||
trans.condition.when.leftOperand,
|
||||
(updates) => {
|
||||
const newCondition = {
|
||||
...trans.condition!,
|
||||
when: { ...trans.condition!.when, leftOperand: updates },
|
||||
};
|
||||
handleTransformationChange(index, { condition: newCondition });
|
||||
},
|
||||
index,
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={trans.condition.when.operator}
|
||||
onValueChange={(value) => {
|
||||
const newCondition = {
|
||||
...trans.condition!,
|
||||
when: { ...trans.condition!.when, operator: value as any },
|
||||
};
|
||||
handleTransformationChange(index, { condition: newCondition });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONDITION_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!["IS_NULL", "IS_NOT_NULL"].includes(trans.condition.when.operator) &&
|
||||
trans.condition.when.rightOperand &&
|
||||
renderOperandSelector(
|
||||
trans.condition.when.rightOperand,
|
||||
(updates) => {
|
||||
const newCondition = {
|
||||
...trans.condition!,
|
||||
when: { ...trans.condition!.when, rightOperand: updates },
|
||||
};
|
||||
handleTransformationChange(index, { condition: newCondition });
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* THEN 절 */}
|
||||
<div className="rounded bg-green-50 p-2">
|
||||
<div className="mb-1 text-xs font-medium text-green-700">THEN</div>
|
||||
{renderOperandSelector(
|
||||
trans.condition.then,
|
||||
(updates) => {
|
||||
const newCondition = { ...trans.condition!, then: updates };
|
||||
handleTransformationChange(index, { condition: newCondition });
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ELSE 절 */}
|
||||
<div className="rounded bg-red-50 p-2">
|
||||
<div className="mb-1 text-xs font-medium text-red-700">ELSE</div>
|
||||
{renderOperandSelector(
|
||||
trans.condition.else,
|
||||
(updates) => {
|
||||
const newCondition = { ...trans.condition!, else: updates };
|
||||
handleTransformationChange(index, { condition: newCondition });
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trans.formulaType === "static" && (
|
||||
<div className="space-y-2 rounded border bg-white p-2">
|
||||
<Label className="text-xs text-gray-600">정적 값</Label>
|
||||
<Input
|
||||
value={trans.staticValue ?? ""}
|
||||
onChange={(e) => handleTransformationChange(index, { staticValue: e.target.value })}
|
||||
placeholder="고정 값 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">문자열, 숫자, true/false 입력 가능</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +236,31 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
console.log("⚠️ REST API 노드에 responseFields 없음");
|
||||
}
|
||||
}
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
// 3️⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
|
||||
else if (node.type === "formulaTransform") {
|
||||
console.log("✅ 수식 변환 노드 발견");
|
||||
|
||||
// 상위 노드의 필드 가져오기
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||
fields.push(...upperResult.fields);
|
||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
|
||||
// 수식 변환 출력 필드 추가
|
||||
const nodeData = node.data as any;
|
||||
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
|
||||
console.log(` 📊 ${nodeData.transformations.length}개 수식 변환 발견`);
|
||||
nodeData.transformations.forEach((trans: any) => {
|
||||
if (trans.outputField) {
|
||||
fields.push({
|
||||
name: trans.outputField,
|
||||
label: trans.outputFieldLabel || trans.outputField,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 4️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
console.log("✅ 집계 노드 발견");
|
||||
const nodeData = node.data as any;
|
||||
@@ -268,7 +292,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
label:
|
||||
aggFunc.outputFieldLabel ||
|
||||
aggFunc.targetFieldLabel ||
|
||||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,7 +212,27 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
fields.push(...upperFields);
|
||||
}
|
||||
}
|
||||
// 2️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
// 2️⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
|
||||
else if (node.type === "formulaTransform") {
|
||||
// 상위 노드의 필드 가져오기
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
|
||||
// 수식 변환 출력 필드 추가
|
||||
const nodeData = node.data as any;
|
||||
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
|
||||
nodeData.transformations.forEach((trans: any) => {
|
||||
if (trans.outputField) {
|
||||
fields.push({
|
||||
name: trans.outputField,
|
||||
label: trans.outputFieldLabel || trans.outputField,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
@@ -240,7 +260,10 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
label:
|
||||
aggFunc.outputFieldLabel ||
|
||||
aggFunc.targetFieldLabel ||
|
||||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -248,7 +271,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 3️⃣ REST API 소스 노드
|
||||
// 4️⃣ REST API 소스 노드
|
||||
else if (node.type === "restAPISource") {
|
||||
foundRestAPI = true;
|
||||
const responseFields = (node.data as any).responseFields;
|
||||
|
||||
@@ -212,7 +212,27 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
// 3️⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
|
||||
else if (node.type === "formulaTransform") {
|
||||
// 상위 노드의 필드 가져오기
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
|
||||
// 수식 변환 출력 필드 추가
|
||||
const nodeData = node.data as any;
|
||||
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
|
||||
nodeData.transformations.forEach((trans: any) => {
|
||||
if (trans.outputField) {
|
||||
fields.push({
|
||||
name: trans.outputField,
|
||||
label: trans.outputFieldLabel || trans.outputField,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 4️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
@@ -240,7 +260,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
label:
|
||||
aggFunc.outputFieldLabel ||
|
||||
aggFunc.targetFieldLabel ||
|
||||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
||||
category: "transform",
|
||||
color: "#A855F7", // 보라색
|
||||
},
|
||||
{
|
||||
type: "formulaTransform",
|
||||
label: "수식 변환",
|
||||
icon: "",
|
||||
description: "산술 연산, 함수, 조건문으로 새 필드를 계산합니다",
|
||||
category: "transform",
|
||||
color: "#F97316", // 오렌지색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
|
||||
Reference in New Issue
Block a user