제어 집계함수 노드 추가

This commit is contained in:
kjs 2025-12-05 15:18:55 +09:00
parent 1c329b5e0c
commit 96321f502f
10 changed files with 959 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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", // 보라색
},
// ========================================================================
// 액션

View File

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