제어 집계함수 노드 추가
This commit is contained in:
parent
1c329b5e0c
commit
96321f502f
|
|
@ -528,6 +528,9 @@ export class NodeFlowExecutionService {
|
|||
case "dataTransform":
|
||||
return this.executeDataTransform(node, inputData, context);
|
||||
|
||||
case "aggregate":
|
||||
return this.executeAggregate(node, inputData, context);
|
||||
|
||||
case "insertAction":
|
||||
return this.executeInsertAction(node, inputData, context, client);
|
||||
|
||||
|
|
@ -3197,4 +3200,161 @@ export class NodeFlowExecutionService {
|
|||
"upsertAction",
|
||||
].includes(nodeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 노드 실행 (SUM, COUNT, AVG, MIN, MAX 등)
|
||||
*/
|
||||
private static async executeAggregate(
|
||||
node: FlowNode,
|
||||
inputData: any,
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data;
|
||||
|
||||
logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`);
|
||||
|
||||
// 입력 데이터가 없으면 빈 배열 반환
|
||||
if (!inputData || !Array.isArray(inputData) || inputData.length === 0) {
|
||||
logger.warn("⚠️ 집계할 입력 데이터가 없습니다.");
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info(`📥 입력 데이터: ${inputData.length}건`);
|
||||
logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`);
|
||||
logger.info(`📊 집계 연산: ${aggregations.length}개`);
|
||||
|
||||
// 그룹화 수행
|
||||
const groups = new Map<string, any[]>();
|
||||
|
||||
for (const row of inputData) {
|
||||
// 그룹 키 생성
|
||||
const groupKey = groupByFields.length > 0
|
||||
? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||")
|
||||
: "__ALL__";
|
||||
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, []);
|
||||
}
|
||||
groups.get(groupKey)!.push(row);
|
||||
}
|
||||
|
||||
logger.info(`📊 그룹 수: ${groups.size}개`);
|
||||
|
||||
// 각 그룹에 대해 집계 수행
|
||||
const results: any[] = [];
|
||||
|
||||
for (const [groupKey, groupRows] of groups) {
|
||||
const resultRow: any = {};
|
||||
|
||||
// 그룹 기준 필드값 추가
|
||||
if (groupByFields.length > 0) {
|
||||
const keyValues = groupKey.split("|||");
|
||||
groupByFields.forEach((field: any, idx: number) => {
|
||||
resultRow[field.field] = keyValues[idx];
|
||||
});
|
||||
}
|
||||
|
||||
// 각 집계 연산 수행
|
||||
for (const agg of aggregations) {
|
||||
const { sourceField, function: aggFunc, outputField } = agg;
|
||||
|
||||
if (!outputField) continue;
|
||||
|
||||
let aggregatedValue: any;
|
||||
|
||||
switch (aggFunc) {
|
||||
case "SUM":
|
||||
aggregatedValue = groupRows.reduce((sum: number, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
break;
|
||||
|
||||
case "COUNT":
|
||||
aggregatedValue = groupRows.length;
|
||||
break;
|
||||
|
||||
case "AVG":
|
||||
const sum = groupRows.reduce((acc: number, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
return acc + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
aggregatedValue = groupRows.length > 0 ? sum / groupRows.length : 0;
|
||||
break;
|
||||
|
||||
case "MIN":
|
||||
aggregatedValue = groupRows.reduce((min: number | null, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
if (isNaN(val)) return min;
|
||||
return min === null ? val : Math.min(min, val);
|
||||
}, null);
|
||||
break;
|
||||
|
||||
case "MAX":
|
||||
aggregatedValue = groupRows.reduce((max: number | null, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
if (isNaN(val)) return max;
|
||||
return max === null ? val : Math.max(max, val);
|
||||
}, null);
|
||||
break;
|
||||
|
||||
case "FIRST":
|
||||
aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null;
|
||||
break;
|
||||
|
||||
case "LAST":
|
||||
aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null;
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(`⚠️ 지원하지 않는 집계 함수: ${aggFunc}`);
|
||||
aggregatedValue = null;
|
||||
}
|
||||
|
||||
resultRow[outputField] = aggregatedValue;
|
||||
logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`);
|
||||
}
|
||||
|
||||
results.push(resultRow);
|
||||
}
|
||||
|
||||
// HAVING 조건 적용 (집계 후 필터링)
|
||||
let filteredResults = results;
|
||||
if (havingConditions && havingConditions.length > 0) {
|
||||
filteredResults = results.filter((row) => {
|
||||
return havingConditions.every((condition: any) => {
|
||||
const fieldValue = row[condition.field];
|
||||
const compareValue = parseFloat(condition.value);
|
||||
|
||||
switch (condition.operator) {
|
||||
case "=":
|
||||
return fieldValue === compareValue;
|
||||
case "!=":
|
||||
return fieldValue !== compareValue;
|
||||
case ">":
|
||||
return fieldValue > compareValue;
|
||||
case ">=":
|
||||
return fieldValue >= compareValue;
|
||||
case "<":
|
||||
return fieldValue < compareValue;
|
||||
case "<=":
|
||||
return fieldValue <= compareValue;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`);
|
||||
}
|
||||
|
||||
logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`);
|
||||
|
||||
// 결과 샘플 출력
|
||||
if (filteredResults.length > 0) {
|
||||
logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2));
|
||||
}
|
||||
|
||||
return filteredResults;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
|||
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||
import { AggregateNode } from "./nodes/AggregateNode";
|
||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
|
|
@ -41,6 +42,7 @@ const nodeTypes = {
|
|||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
dataTransform: DataTransformNode,
|
||||
aggregate: AggregateNode,
|
||||
// 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 (Aggregate Node)
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Layers } from "lucide-react";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
// 집계 함수별 아이콘/라벨
|
||||
const AGGREGATE_FUNCTION_LABELS: Record<AggregateFunction, string> = {
|
||||
SUM: "합계",
|
||||
COUNT: "개수",
|
||||
AVG: "평균",
|
||||
MIN: "최소",
|
||||
MAX: "최대",
|
||||
FIRST: "첫번째",
|
||||
LAST: "마지막",
|
||||
};
|
||||
|
||||
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
|
||||
const groupByCount = data.groupByFields?.length || 0;
|
||||
const aggregationCount = data.aggregations?.length || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 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">
|
||||
{groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3 space-y-3">
|
||||
{/* 그룹 기준 */}
|
||||
{groupByCount > 0 && (
|
||||
<div className="rounded bg-purple-50 p-2">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Layers className="h-3 w-3 text-purple-600" />
|
||||
<span className="text-xs font-medium text-purple-700">그룹 기준</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.groupByFields.slice(0, 3).map((field, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
{data.groupByFields.length > 3 && (
|
||||
<span className="text-xs text-purple-500">+{data.groupByFields.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 집계 연산 */}
|
||||
{aggregationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.aggregations.slice(0, 4).map((agg, idx) => (
|
||||
<div key={agg.id || idx} className="rounded bg-gray-50 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded bg-purple-600 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{agg.outputFieldLabel || agg.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
{agg.sourceFieldLabel || agg.sourceField}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.aggregations.length > 4 && (
|
||||
<div className="text-xs text-gray-400 text-center">
|
||||
... 외 {data.aggregations.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-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AggregateNode.displayName = "AggregateNode";
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
|||
import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties";
|
||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||
import { AggregateProperties } from "./properties/AggregateProperties";
|
||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||
import { CommentProperties } from "./properties/CommentProperties";
|
||||
import { LogProperties } from "./properties/LogProperties";
|
||||
|
|
@ -122,6 +123,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
|||
case "dataTransform":
|
||||
return <DataTransformProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "aggregate":
|
||||
return <AggregateProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "restAPISource":
|
||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
|
|
@ -157,9 +161,11 @@ function getNodeTypeLabel(type: NodeType): string {
|
|||
tableSource: "테이블 소스",
|
||||
externalDBSource: "외부 DB 소스",
|
||||
restAPISource: "REST API 소스",
|
||||
referenceLookup: "참조 조회",
|
||||
condition: "조건 분기",
|
||||
fieldMapping: "필드 매핑",
|
||||
dataTransform: "데이터 변환",
|
||||
aggregate: "집계",
|
||||
insertAction: "INSERT 액션",
|
||||
updateAction: "UPDATE 액션",
|
||||
deleteAction: "DELETE 액션",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,526 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 속성 편집 패널
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산 설정
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Calculator, Layers, Filter } 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
interface AggregatePropertiesProps {
|
||||
nodeId: string;
|
||||
data: AggregateNodeData;
|
||||
}
|
||||
|
||||
// 집계 함수 옵션
|
||||
const AGGREGATE_FUNCTIONS: Array<{ value: AggregateFunction; label: string; description: string }> = [
|
||||
{ value: "SUM", label: "합계 (SUM)", description: "숫자 필드의 합계를 계산합니다" },
|
||||
{ value: "COUNT", label: "개수 (COUNT)", description: "레코드 개수를 계산합니다" },
|
||||
{ value: "AVG", label: "평균 (AVG)", description: "숫자 필드의 평균을 계산합니다" },
|
||||
{ value: "MIN", label: "최소 (MIN)", description: "최소값을 찾습니다" },
|
||||
{ value: "MAX", label: "최대 (MAX)", description: "최대값을 찾습니다" },
|
||||
{ value: "FIRST", label: "첫번째 (FIRST)", description: "그룹의 첫 번째 값을 가져옵니다" },
|
||||
{ value: "LAST", label: "마지막 (LAST)", description: "그룹의 마지막 값을 가져옵니다" },
|
||||
];
|
||||
|
||||
// 비교 연산자 옵션
|
||||
const OPERATORS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "보다 큼 (>)" },
|
||||
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||
{ value: "<", label: "보다 작음 (<)" },
|
||||
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||
];
|
||||
|
||||
export function AggregateProperties({ nodeId, data }: AggregatePropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
// 로컬 상태
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "집계");
|
||||
const [groupByFields, setGroupByFields] = useState(data.groupByFields || []);
|
||||
const [aggregations, setAggregations] = useState(data.aggregations || []);
|
||||
const [havingConditions, setHavingConditions] = useState(data.havingConditions || []);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string; type?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "집계");
|
||||
setGroupByFields(data.groupByFields || []);
|
||||
setAggregations(data.aggregations || []);
|
||||
setHavingConditions(data.havingConditions || []);
|
||||
}, [data]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기
|
||||
useEffect(() => {
|
||||
const inputEdges = edges.filter((edge) => edge.target === nodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string; type?: string }> = [];
|
||||
sourceNodes.forEach((node) => {
|
||||
if (node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
type: field.type,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 저장 함수
|
||||
const saveToNode = useCallback(
|
||||
(updates: Partial<AggregateNodeData>) => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
groupByFields,
|
||||
aggregations,
|
||||
havingConditions,
|
||||
...updates,
|
||||
});
|
||||
},
|
||||
[nodeId, updateNode, displayName, groupByFields, aggregations, havingConditions]
|
||||
);
|
||||
|
||||
// 그룹 기준 필드 토글
|
||||
const handleGroupByToggle = (fieldName: string, checked: boolean) => {
|
||||
let newGroupByFields;
|
||||
if (checked) {
|
||||
const field = sourceFields.find((f) => f.name === fieldName);
|
||||
newGroupByFields = [...groupByFields, { field: fieldName, fieldLabel: field?.label }];
|
||||
} else {
|
||||
newGroupByFields = groupByFields.filter((f) => f.field !== fieldName);
|
||||
}
|
||||
setGroupByFields(newGroupByFields);
|
||||
saveToNode({ groupByFields: newGroupByFields });
|
||||
};
|
||||
|
||||
// 집계 연산 추가
|
||||
const handleAddAggregation = () => {
|
||||
const newAggregation = {
|
||||
id: `agg_${Date.now()}`,
|
||||
sourceField: "",
|
||||
sourceFieldLabel: "",
|
||||
function: "SUM" as AggregateFunction,
|
||||
outputField: "",
|
||||
outputFieldLabel: "",
|
||||
};
|
||||
const newAggregations = [...aggregations, newAggregation];
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// 집계 연산 삭제
|
||||
const handleRemoveAggregation = (index: number) => {
|
||||
const newAggregations = aggregations.filter((_, i) => i !== index);
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// 집계 연산 변경
|
||||
const handleAggregationChange = (index: number, field: string, value: any) => {
|
||||
const newAggregations = [...aggregations];
|
||||
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newAggregations[index] = {
|
||||
...newAggregations[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
// 출력 필드명 자동 생성 (예: sum_amount)
|
||||
outputField:
|
||||
newAggregations[index].outputField ||
|
||||
`${newAggregations[index].function.toLowerCase()}_${value}`,
|
||||
};
|
||||
} else if (field === "function") {
|
||||
newAggregations[index] = {
|
||||
...newAggregations[index],
|
||||
function: value,
|
||||
// 출력 필드명 업데이트
|
||||
outputField: newAggregations[index].sourceField
|
||||
? `${value.toLowerCase()}_${newAggregations[index].sourceField}`
|
||||
: newAggregations[index].outputField,
|
||||
};
|
||||
} else {
|
||||
newAggregations[index] = { ...newAggregations[index], [field]: value };
|
||||
}
|
||||
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// HAVING 조건 추가
|
||||
const handleAddHavingCondition = () => {
|
||||
const newCondition = {
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
};
|
||||
const newConditions = [...havingConditions, newCondition];
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// HAVING 조건 삭제
|
||||
const handleRemoveHavingCondition = (index: number) => {
|
||||
const newConditions = havingConditions.filter((_, i) => i !== index);
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// HAVING 조건 변경
|
||||
const handleHavingConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...havingConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// 집계 결과 필드 목록 (HAVING 조건에서 선택용)
|
||||
const aggregatedFields = aggregations
|
||||
.filter((agg) => agg.outputField)
|
||||
.map((agg) => ({
|
||||
name: agg.outputField,
|
||||
label: agg.outputFieldLabel || agg.outputField,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-purple-50 p-2">
|
||||
<Calculator className="h-4 w-4 text-purple-600" />
|
||||
<span className="font-semibold text-purple-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">
|
||||
<Layers className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">그룹 기준 필드</h3>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">
|
||||
선택한 필드를 기준으로 데이터를 그룹화합니다. 선택하지 않으면 전체 데이터를 하나의 그룹으로 처리합니다.
|
||||
</p>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
<div className="space-y-1">
|
||||
{sourceFields.map((field) => {
|
||||
const isChecked = groupByFields.some((f) => f.field === field.name);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-purple-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`groupby_${field.name}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => handleGroupByToggle(field.name, checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`groupby_${field.name}`}
|
||||
className="flex-1 cursor-pointer text-xs"
|
||||
>
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="ml-1 text-gray-400">({field.name})</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupByFields.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{groupByFields.map((field) => (
|
||||
<span
|
||||
key={field.field}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
</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-purple-600" />
|
||||
<h3 className="text-sm font-semibold">집계 연산</h3>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddAggregation} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
연산 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">SUM, COUNT, AVG 등 집계 연산을 설정합니다.</p>
|
||||
|
||||
{aggregations.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">
|
||||
{aggregations.map((agg, index) => (
|
||||
<div key={agg.id || index} className="rounded border bg-purple-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-700">집계 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveAggregation(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 집계 함수 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">집계 함수</Label>
|
||||
<Select
|
||||
value={agg.function}
|
||||
onValueChange={(value) =>
|
||||
handleAggregationChange(index, "function", value as AggregateFunction)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGGREGATE_FUNCTIONS.map((func) => (
|
||||
<SelectItem key={func.value} value={func.value} className="text-xs">
|
||||
<div>
|
||||
<div className="font-medium">{func.label}</div>
|
||||
<div className="text-gray-400">{func.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 소스 필드 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={agg.sourceField || ""}
|
||||
onValueChange={(value) => handleAggregationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 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((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드명 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드명</Label>
|
||||
<Input
|
||||
value={agg.outputField || ""}
|
||||
onChange={(e) => handleAggregationChange(index, "outputField", e.target.value)}
|
||||
placeholder="예: total_amount"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
집계 결과가 저장될 필드명입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드 라벨 (선택)</Label>
|
||||
<Input
|
||||
value={agg.outputFieldLabel || ""}
|
||||
onChange={(e) => handleAggregationChange(index, "outputFieldLabel", e.target.value)}
|
||||
placeholder="예: 총 금액"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* HAVING 조건 (선택) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">집계 후 필터 (HAVING)</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddHavingCondition}
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={aggregations.length === 0}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">집계 결과에 대한 필터링 조건을 설정합니다 (선택 사항).</p>
|
||||
|
||||
{havingConditions.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-400">
|
||||
집계 후 필터링이 필요하면 조건을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{havingConditions.map((condition, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border bg-gray-50 p-2">
|
||||
{/* 집계 결과 필드 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => handleHavingConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aggregatedFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator || "="}
|
||||
onValueChange={(value) => handleHavingConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 비교값 */}
|
||||
<Input
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => handleHavingConditionChange(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveHavingCondition(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{(groupByFields.length > 0 || aggregations.length > 0) && (
|
||||
<div className="rounded border bg-gray-50 p-3">
|
||||
<h4 className="mb-2 text-xs font-semibold text-gray-700">집계 결과 미리보기</h4>
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">그룹 기준:</span>{" "}
|
||||
{groupByFields.length > 0
|
||||
? groupByFields.map((f) => f.fieldLabel || f.field).join(", ")
|
||||
: "전체 (그룹 없음)"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">집계 컬럼:</span>{" "}
|
||||
{aggregations.length > 0
|
||||
? aggregations
|
||||
.filter((a) => a.outputField)
|
||||
.map((a) => `${a.function}(${a.sourceFieldLabel || a.sourceField}) → ${a.outputFieldLabel || a.outputField}`)
|
||||
.join(", ")
|
||||
: "없음"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +236,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
console.log("⚠️ REST API 노드에 responseFields 없음");
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
console.log("✅ 집계 노드 발견");
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
console.log(` 📊 ${nodeData.groupByFields.length}개 그룹 필드 발견`);
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
console.log(` 📊 ${aggregations.length}개 집계 함수 발견`);
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id;
|
||||
|
|
@ -266,7 +307,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,43 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
fields.push(...upperFields);
|
||||
}
|
||||
}
|
||||
// 2️⃣ REST API 소스 노드
|
||||
// 2️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 3️⃣ REST API 소스 노드
|
||||
else if (node.type === "restAPISource") {
|
||||
foundRestAPI = true;
|
||||
const responseFields = (node.data as any).responseFields;
|
||||
|
|
@ -229,7 +265,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
|
||||
|
|
@ -251,7 +287,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,43 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
|
||||
|
|
@ -234,7 +270,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
},
|
||||
{
|
||||
type: "aggregate",
|
||||
label: "집계",
|
||||
icon: "",
|
||||
description: "SUM, COUNT, AVG 등 집계 연산을 수행합니다",
|
||||
category: "transform",
|
||||
color: "#A855F7", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type NodeType =
|
|||
| "referenceLookup" // 참조 테이블 조회 (내부 DB 전용)
|
||||
| "condition" // 조건 분기
|
||||
| "dataTransform" // 데이터 변환
|
||||
| "aggregate" // 집계 노드 (SUM, COUNT, AVG 등)
|
||||
| "insertAction" // INSERT 액션
|
||||
| "updateAction" // UPDATE 액션
|
||||
| "deleteAction" // DELETE 액션
|
||||
|
|
@ -194,6 +195,34 @@ export interface DataTransformNodeData {
|
|||
displayName?: string;
|
||||
}
|
||||
|
||||
// 집계 함수 타입
|
||||
export type AggregateFunction = "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "FIRST" | "LAST";
|
||||
|
||||
// 집계 노드 데이터
|
||||
export interface AggregateNodeData {
|
||||
displayName?: string;
|
||||
// 그룹 기준 컬럼들
|
||||
groupByFields: Array<{
|
||||
field: string; // 컬럼명
|
||||
fieldLabel?: string; // 라벨
|
||||
}>;
|
||||
// 집계 연산들
|
||||
aggregations: Array<{
|
||||
id: string; // 고유 ID
|
||||
sourceField: string; // 집계할 소스 필드
|
||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
||||
function: AggregateFunction; // 집계 함수
|
||||
outputField: string; // 출력 필드명
|
||||
outputFieldLabel?: string; // 출력 필드 라벨
|
||||
}>;
|
||||
// 집계 후 필터링 (HAVING 절)
|
||||
havingConditions?: Array<{
|
||||
field: string; // 집계 결과 필드
|
||||
operator: string; // 비교 연산자
|
||||
value: any; // 비교값
|
||||
}>;
|
||||
}
|
||||
|
||||
// INSERT 액션 노드
|
||||
export interface InsertActionNodeData {
|
||||
displayName?: string;
|
||||
|
|
@ -406,6 +435,7 @@ export type NodeData =
|
|||
| ConditionNodeData
|
||||
| FieldMappingNodeData
|
||||
| DataTransformNodeData
|
||||
| AggregateNodeData
|
||||
| InsertActionNodeData
|
||||
| UpdateActionNodeData
|
||||
| DeleteActionNodeData
|
||||
|
|
|
|||
Loading…
Reference in New Issue