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 { Loader2, FileJson, Calendar, Trash2 } from "lucide-react";
|
2025-12-05 10:46:10 +09:00
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
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
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
|
import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows";
|
|
|
|
|
|
|
|
|
|
interface Flow {
|
|
|
|
|
flowId: number;
|
|
|
|
|
flowName: string;
|
|
|
|
|
flowDescription: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface LoadFlowDialogProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
onLoad: (flowId: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function LoadFlowDialog({ open, onOpenChange, onLoad }: LoadFlowDialogProps) {
|
|
|
|
|
const [flows, setFlows] = useState<Flow[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
|
|
|
|
|
const [deleting, setDeleting] = useState<number | null>(null);
|
|
|
|
|
|
|
|
|
|
// 플로우 목록 조회
|
|
|
|
|
const fetchFlows = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const flows = await getNodeFlows();
|
|
|
|
|
setFlows(flows);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("플로우 목록 조회 오류:", error);
|
|
|
|
|
alert(error instanceof Error ? error.message : "플로우 목록을 불러올 수 없습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 플로우 삭제
|
|
|
|
|
const handleDelete = async (flowId: number, flowName: string) => {
|
|
|
|
|
if (!confirm(`"${flowName}" 플로우를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDeleting(flowId);
|
|
|
|
|
try {
|
|
|
|
|
await deleteNodeFlow(flowId);
|
|
|
|
|
alert("✅ 플로우가 삭제되었습니다.");
|
|
|
|
|
fetchFlows(); // 목록 새로고침
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("플로우 삭제 오류:", error);
|
|
|
|
|
alert(error instanceof Error ? error.message : "플로우를 삭제할 수 없습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setDeleting(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 플로우 불러오기
|
|
|
|
|
const handleLoad = () => {
|
|
|
|
|
if (selectedFlowId === null) {
|
|
|
|
|
alert("불러올 플로우를 선택해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onLoad(selectedFlowId);
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 다이얼로그 열릴 때 목록 조회
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
fetchFlows();
|
|
|
|
|
setSelectedFlowId(null);
|
|
|
|
|
}
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
// 날짜 포맷팅
|
|
|
|
|
const formatDate = (dateString: string) => {
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
return new Intl.DateTimeFormat("ko-KR", {
|
|
|
|
|
year: "numeric",
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
}).format(date);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-2xl">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>플로우 불러오기</DialogTitle>
|
|
|
|
|
<DialogDescription>저장된 플로우를 선택하여 불러옵니다.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
|
|
|
|
</div>
|
|
|
|
|
) : flows.length === 0 ? (
|
|
|
|
|
<div className="py-12 text-center">
|
|
|
|
|
<FileJson className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
|
|
|
|
<p className="text-sm text-gray-500">저장된 플로우가 없습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<ScrollArea className="h-[400px]">
|
|
|
|
|
<div className="space-y-2 pr-4">
|
|
|
|
|
{flows.map((flow) => (
|
|
|
|
|
<div
|
|
|
|
|
key={flow.flowId}
|
|
|
|
|
className={`cursor-pointer rounded-lg border-2 p-4 transition-all hover:border-blue-300 hover:bg-blue-50 ${
|
|
|
|
|
selectedFlowId === flow.flowId ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => setSelectedFlowId(flow.flowId)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<h3 className="font-semibold text-gray-900">{flow.flowName}</h3>
|
|
|
|
|
<span className="text-xs text-gray-400">#{flow.flowId}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{flow.flowDescription && <p className="mt-1 text-sm text-gray-600">{flow.flowDescription}</p>}
|
|
|
|
|
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Calendar className="h-3 w-3" />
|
|
|
|
|
<span>수정: {formatDate(flow.updatedAt)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleDelete(flow.flowId, flow.flowName);
|
|
|
|
|
}}
|
|
|
|
|
disabled={deleting === flow.flowId}
|
|
|
|
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
|
|
|
|
>
|
|
|
|
|
{deleting === flow.flowId ? (
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleLoad} disabled={selectedFlowId === null || loading}>
|
|
|
|
|
불러오기
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|