527 lines
21 KiB
TypeScript
527 lines
21 KiB
TypeScript
"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>
|
|
);
|
|
}
|
|
|