feat: 노드 기반 데이터 플로우 시스템 구현
- 노드 에디터 UI 구현 (React Flow 기반)
- TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드
- 드래그앤드롭 노드 추가 및 연결
- 속성 패널을 통한 노드 설정
- 실시간 필드 라벨 표시 (column_labels 테이블 연동)
- 데이터 변환 노드 (DataTransform) 기능
- EXPLODE: 구분자로 1개 행 → 여러 행 확장
- UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입
- In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기)
- 변환된 필드가 하위 액션 노드에 자동 전달
- 노드 플로우 실행 엔진
- 위상 정렬을 통한 노드 실행 순서 결정
- 레벨별 병렬 실행 (Promise.allSettled)
- 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵)
- 트랜잭션 기반 안전한 데이터 처리
- UPSERT 액션 로직 구현
- DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식
- 복합 충돌 키 지원 (예: sales_no + product_name)
- 파라미터 인덱스 정확한 매핑
- 데이터 소스 자동 감지
- 테이블 선택 데이터 (selectedRowsData) 자동 주입
- 폼 입력 데이터 (formData) 자동 주입
- TableSource 노드가 외부 데이터 우선 사용
- 버튼 컴포넌트 통합
- 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원
- 노드 플로우 선택 UI 추가
- API 클라이언트 통합 (Axios)
- 개발 문서 작성
- 노드 기반 제어 시스템 개선 계획
- 노드 연결 규칙 설계
- 노드 실행 엔진 설계
- 노드 구조 개선안
- 버튼 통합 분석
2025-10-02 16:22:29 +09:00
|
|
|
"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">
|
2025-10-24 14:11:12 +09:00
|
|
|
<div className="space-y-4 p-4 pb-8">
|
feat: 노드 기반 데이터 플로우 시스템 구현
- 노드 에디터 UI 구현 (React Flow 기반)
- TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드
- 드래그앤드롭 노드 추가 및 연결
- 속성 패널을 통한 노드 설정
- 실시간 필드 라벨 표시 (column_labels 테이블 연동)
- 데이터 변환 노드 (DataTransform) 기능
- EXPLODE: 구분자로 1개 행 → 여러 행 확장
- UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입
- In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기)
- 변환된 필드가 하위 액션 노드에 자동 전달
- 노드 플로우 실행 엔진
- 위상 정렬을 통한 노드 실행 순서 결정
- 레벨별 병렬 실행 (Promise.allSettled)
- 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵)
- 트랜잭션 기반 안전한 데이터 처리
- UPSERT 액션 로직 구현
- DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식
- 복합 충돌 키 지원 (예: sales_no + product_name)
- 파라미터 인덱스 정확한 매핑
- 데이터 소스 자동 감지
- 테이블 선택 데이터 (selectedRowsData) 자동 주입
- 폼 입력 데이터 (formData) 자동 주입
- TableSource 노드가 외부 데이터 우선 사용
- 버튼 컴포넌트 통합
- 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원
- 노드 플로우 선택 UI 추가
- API 클라이언트 통합 (Axios)
- 개발 문서 작성
- 노드 기반 제어 시스템 개선 계획
- 노드 연결 규칙 설계
- 노드 실행 엔진 설계
- 노드 구조 개선안
- 버튼 통합 분석
2025-10-02 16:22:29 +09:00
|
|
|
{/* 헤더 */}
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|