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

460 lines
17 KiB
TypeScript

"use client";
/**
* 데이터 변환 노드 속성 편집 (개선 버전)
*/
import { useEffect, useState } from "react";
import { Plus, Trash2, Wand2, ArrowRight } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { DataTransformNodeData } from "@/types/node-editor";
interface DataTransformPropertiesProps {
nodeId: string;
data: DataTransformNodeData;
}
const TRANSFORM_TYPES = [
{ value: "UPPERCASE", label: "대문자 변환", category: "기본" },
{ value: "LOWERCASE", label: "소문자 변환", category: "기본" },
{ value: "TRIM", label: "공백 제거", category: "기본" },
{ value: "CONCAT", label: "문자열 결합", category: "기본" },
{ value: "SPLIT", label: "문자열 분리", category: "기본" },
{ value: "REPLACE", label: "문자열 치환", category: "기본" },
{ value: "EXPLODE", label: "행 확장 (1→N)", category: "고급" },
{ value: "CAST", label: "타입 변환", category: "고급" },
{ value: "FORMAT", label: "형식화", category: "고급" },
{ value: "CALCULATE", label: "계산식", category: "고급" },
{ value: "JSON_EXTRACT", label: "JSON 추출", category: "고급" },
{ value: "CUSTOM", label: "사용자 정의", category: "고급" },
] as const;
export function DataTransformProperties({ nodeId, data }: DataTransformPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
const [displayName, setDisplayName] = useState(data.displayName || "데이터 변환");
const [transformations, setTransformations] = useState(data.transformations || []);
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || "데이터 변환");
setTransformations(data.transformations || []);
}, [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 }> = [];
sourceNodes.forEach((node) => {
if (node.type === "tableSource" && node.data.fields) {
node.data.fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
});
});
} else if (node.type === "externalDBSource" && node.data.fields) {
node.data.fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
});
});
}
});
setSourceFields(fields);
}, [nodeId, nodes, edges]);
const handleAddTransformation = () => {
setTransformations([
...transformations,
{
type: "UPPERCASE" as const,
sourceField: "",
targetField: "",
},
]);
};
const handleRemoveTransformation = (index: number) => {
const newTransformations = transformations.filter((_, i) => i !== index);
setTransformations(newTransformations);
// 즉시 반영
updateNode(nodeId, {
displayName,
transformations: newTransformations,
});
};
const handleTransformationChange = (index: number, field: string, value: any) => {
const newTransformations = [...transformations];
// 필드 변경 시 라벨도 함께 저장
if (field === "sourceField") {
const sourceField = sourceFields.find((f) => f.name === value);
newTransformations[index] = {
...newTransformations[index],
sourceField: value,
sourceFieldLabel: sourceField?.label,
};
} else if (field === "targetField") {
// 타겟 필드는 새로 생성하는 필드이므로 라벨은 사용자가 직접 입력
newTransformations[index] = {
...newTransformations[index],
targetField: value,
};
} else {
newTransformations[index] = { ...newTransformations[index], [field]: value };
}
setTransformations(newTransformations);
};
const handleSave = () => {
updateNode(nodeId, {
displayName,
transformations,
});
};
const renderTransformationFields = (transform: any, index: number) => {
const commonFields = (
<>
{/* 소스 필드 */}
{transform.type !== "CONCAT" && (
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={transform.sourceField || ""}
onValueChange={(value) => handleTransformationChange(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={transform.targetField || ""}
onChange={(e) => handleTransformationChange(index, "targetField", e.target.value)}
placeholder="비어있으면 소스 필드에 적용"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400">
{transform.targetField ? (
transform.targetField === transform.sourceField ? (
<span className="text-indigo-600"> </span>
) : (
<span className="text-green-600"> </span>
)
) : (
<span className="text-indigo-600">비어있음: 소스 </span>
)}
</p>
</div>
</>
);
// 타입별 추가 필드
switch (transform.type) {
case "EXPLODE":
return (
<>
{commonFields}
<div>
<Label className="text-xs text-gray-600"></Label>
<Input
value={transform.delimiter || ","}
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
placeholder="예: , 또는 ; 또는 |"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</>
);
case "CONCAT":
return (
<>
{/* CONCAT은 다중 소스 필드를 지원 - 간소화 버전 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={transform.sourceField || ""}
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="첫 번째 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name} className="text-xs">
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"></Label>
<Input
value={transform.separator || " "}
onChange={(e) => handleTransformationChange(index, "separator", e.target.value)}
placeholder="예: 공백 또는 , 또는 -"
className="mt-1 h-8 text-xs"
/>
</div>
{commonFields}
</>
);
case "SPLIT":
return (
<>
{commonFields}
<div>
<Label className="text-xs text-gray-600"></Label>
<Input
value={transform.delimiter || ","}
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
placeholder="예: , 또는 ; 또는 |"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-gray-600"> (0 )</Label>
<Input
type="number"
value={transform.splitIndex !== undefined ? transform.splitIndex : 0}
onChange={(e) => handleTransformationChange(index, "splitIndex", parseInt(e.target.value))}
placeholder="0"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400"> (0=)</p>
</div>
</>
);
case "REPLACE":
return (
<>
{commonFields}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Input
value={transform.searchValue || ""}
onChange={(e) => handleTransformationChange(index, "searchValue", e.target.value)}
placeholder="예: old"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-gray-600"> </Label>
<Input
value={transform.replaceValue || ""}
onChange={(e) => handleTransformationChange(index, "replaceValue", e.target.value)}
placeholder="예: new"
className="mt-1 h-8 text-xs"
/>
</div>
</>
);
case "CAST":
return (
<>
{commonFields}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={transform.castType || "string"}
onValueChange={(value) => handleTransformationChange(index, "castType", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string" className="text-xs">
(String)
</SelectItem>
<SelectItem value="number" className="text-xs">
(Number)
</SelectItem>
<SelectItem value="boolean" className="text-xs">
(Boolean)
</SelectItem>
<SelectItem value="date" className="text-xs">
(Date)
</SelectItem>
</SelectContent>
</Select>
</div>
</>
);
case "CALCULATE":
case "FORMAT":
case "JSON_EXTRACT":
case "CUSTOM":
return (
<>
{commonFields}
<div>
<Label className="text-xs text-gray-600"></Label>
<Input
value={transform.expression || ""}
onChange={(e) => handleTransformationChange(index, "expression", e.target.value)}
placeholder="예: field1 + field2"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400">
{transform.type === "CALCULATE" && "계산 수식을 입력하세요 (예: field1 + field2)"}
{transform.type === "FORMAT" && "형식 문자열을 입력하세요 (예: {0}-{1})"}
{transform.type === "JSON_EXTRACT" && "JSON 경로를 입력하세요 (예: $.data.name)"}
{transform.type === "CUSTOM" && "JavaScript 표현식을 입력하세요"}
</p>
</div>
</>
);
default:
// UPPERCASE, LOWERCASE, TRIM 등
return commonFields;
}
};
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4 pb-8">
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
<Wand2 className="h-4 w-4 text-indigo-600" />
<span className="font-semibold text-indigo-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)}
className="mt-1"
placeholder="노드 표시 이름"
/>
</div>
</div>
{/* 변환 규칙 */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-gray-500"> </p>
</div>
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{transformations.length === 0 ? (
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
</div>
) : (
<div className="space-y-3">
{transformations.map((transform, index) => (
<div key={index} className="rounded border bg-indigo-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-indigo-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveTransformation(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={transform.type}
onValueChange={(value) => handleTransformationChange(index, "type", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<div className="px-2 py-1 text-xs font-semibold text-gray-500"> </div>
{TRANSFORM_TYPES.filter((t) => t.category === "기본").map((type) => (
<SelectItem key={type.value} value={type.value} className="text-xs">
{type.label}
</SelectItem>
))}
<div className="px-2 py-1 text-xs font-semibold text-gray-500"> </div>
{TRANSFORM_TYPES.filter((t) => t.category === "고급").map((type) => (
<SelectItem key={type.value} value={type.value} className="text-xs">
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 타입별 필드 렌더링 */}
{renderTransformationFields(transform, index)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</ScrollArea>
);
}