ERP-node/frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx

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