수식 노드 구현

This commit is contained in:
kjs
2025-12-10 18:28:27 +09:00
parent 3188bc0513
commit 088596480f
10 changed files with 1957 additions and 134 deletions

View File

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

View File

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

View File

@@ -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 액션",

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "transform",
color: "#A855F7", // 보라색
},
{
type: "formulaTransform",
label: "수식 변환",
icon: "",
description: "산술 연산, 함수, 조건문으로 새 필드를 계산합니다",
category: "transform",
color: "#F97316", // 오렌지색
},
// ========================================================================
// 액션