322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Database, Filter, Zap, CheckCircle, XCircle, Edit } from "lucide-react";
|
|
import { DataConnectionState } from "../types/redesigned";
|
|
|
|
interface DataflowVisualizationProps {
|
|
state: Partial<DataConnectionState> & {
|
|
dataflowActions?: Array<{
|
|
actionType: string;
|
|
targetTable?: string;
|
|
name?: string;
|
|
fieldMappings?: any[];
|
|
}>;
|
|
};
|
|
onEdit: (step: "source" | "conditions" | "actions") => void;
|
|
}
|
|
|
|
/**
|
|
* 🎯 Sankey 다이어그램 스타일 데이터 흐름 시각화
|
|
*/
|
|
export const DataflowVisualization: React.FC<DataflowVisualizationProps> = ({ state, onEdit }) => {
|
|
const { fromTable, toTable, controlConditions = [], dataflowActions = [], fromColumns = [], toColumns = [] } = state;
|
|
|
|
// 상태 계산
|
|
const hasSource = !!fromTable;
|
|
const hasConditions = controlConditions.length > 0;
|
|
const hasActions = dataflowActions.length > 0;
|
|
const isComplete = hasSource && hasActions;
|
|
|
|
// 필드명을 라벨명으로 변환하는 함수
|
|
const getFieldLabel = (fieldName: string) => {
|
|
// fromColumns와 toColumns에서 해당 필드 찾기
|
|
const allColumns = [...fromColumns, ...toColumns];
|
|
const column = allColumns.find((col) => col.columnName === fieldName);
|
|
return column?.displayName || column?.labelKo || fieldName;
|
|
};
|
|
|
|
// 테이블명을 라벨명으로 변환하는 함수
|
|
const getTableLabel = (tableName: string) => {
|
|
// fromTable 또는 toTable의 라벨 반환
|
|
if (fromTable?.tableName === tableName) {
|
|
return fromTable?.tableLabel || fromTable?.displayName || tableName;
|
|
}
|
|
if (toTable?.tableName === tableName) {
|
|
return toTable?.tableLabel || toTable?.displayName || tableName;
|
|
}
|
|
return tableName;
|
|
};
|
|
|
|
// 액션 그룹별로 대표 액션 1개씩만 표시
|
|
const actionGroups = dataflowActions.reduce(
|
|
(acc, action, index) => {
|
|
// 각 액션을 개별 그룹으로 처리 (실제로는 actionGroups에서 온 것)
|
|
const groupKey = `group_${index}`;
|
|
acc[groupKey] = [action];
|
|
return acc;
|
|
},
|
|
{} as Record<string, typeof dataflowActions>,
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-gray-600">전체 데이터 흐름을 한눈에 확인</p>
|
|
<Badge variant={isComplete ? "default" : "secondary"} className="text-sm">
|
|
{isComplete ? "✅ 설정 완료" : "⚠️ 설정 필요"}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Sankey 다이어그램 */}
|
|
<div className="relative flex items-center justify-center py-12">
|
|
{/* 연결선 레이어 */}
|
|
<svg className="absolute inset-0 h-full w-full" style={{ zIndex: 0 }}>
|
|
{/* 소스 → 조건 선 */}
|
|
{hasSource && <line x1="25%" y1="50%" x2="50%" y2="50%" stroke="#60a5fa" strokeWidth="3" />}
|
|
|
|
{/* 조건 → 액션들 선 (여러 개) */}
|
|
{hasConditions &&
|
|
hasActions &&
|
|
Object.keys(actionGroups).map((groupKey, index) => {
|
|
const totalActions = Object.keys(actionGroups).length;
|
|
const startY = 50; // 조건 노드 중앙
|
|
// 액션이 여러 개면 위에서 아래로 분산
|
|
const endY =
|
|
totalActions > 1
|
|
? 30 + (index * 40) / (totalActions - 1) // 30%~70% 사이에 분산
|
|
: 50; // 액션이 1개면 중앙
|
|
|
|
return (
|
|
<line
|
|
key={groupKey}
|
|
x1="50%"
|
|
y1={`${startY}%`}
|
|
x2="75%"
|
|
y2={`${endY}%`}
|
|
stroke="#34d399"
|
|
strokeWidth="2"
|
|
opacity="0.7"
|
|
/>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
<div className="relative flex w-full items-center justify-around" style={{ zIndex: 1 }}>
|
|
{/* 1. 소스 노드 */}
|
|
<div className="flex flex-col items-center" style={{ width: "28%" }}>
|
|
<Card
|
|
className={`w-full cursor-pointer border-2 transition-all hover:shadow-lg ${
|
|
hasSource ? "border-blue-400 bg-blue-50" : "border-gray-300 bg-gray-50"
|
|
}`}
|
|
onClick={() => onEdit("source")}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Database className="h-5 w-5 text-blue-600" />
|
|
<span className="text-sm font-semibold text-gray-900">데이터 소스</span>
|
|
</div>
|
|
{hasSource ? (
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{fromTable.tableLabel || fromTable.displayName || fromTable.tableName}
|
|
</p>
|
|
{(fromTable.tableLabel || fromTable.displayName) && (
|
|
<p className="text-xs text-gray-500">({fromTable.tableName})</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-500">미설정</p>
|
|
)}
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
|
<Edit className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 2. 조건 노드 (중앙) */}
|
|
<div className="flex flex-col items-center" style={{ width: "28%" }}>
|
|
<Card
|
|
className={`w-full cursor-pointer border-2 transition-all hover:shadow-lg ${
|
|
hasConditions ? "border-yellow-400 bg-yellow-50" : "border-gray-300 bg-gray-50"
|
|
}`}
|
|
onClick={() => onEdit("conditions")}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Filter className="h-5 w-5 text-yellow-600" />
|
|
<span className="text-sm font-semibold text-gray-900">조건 검증</span>
|
|
</div>
|
|
{hasConditions ? (
|
|
<div className="space-y-2">
|
|
{/* 실제 조건들 표시 */}
|
|
<div className="max-h-32 space-y-1 overflow-y-auto">
|
|
{controlConditions.slice(0, 3).map((condition, index) => (
|
|
<div key={index} className="rounded bg-white/50 px-2 py-1">
|
|
<p className="text-xs font-medium text-gray-800">
|
|
{index > 0 && (
|
|
<span className="mr-1 font-bold text-blue-600">
|
|
{condition.logicalOperator || "AND"}
|
|
</span>
|
|
)}
|
|
<span className="text-blue-800">{getFieldLabel(condition.field)}</span>{" "}
|
|
<span className="text-gray-600">{condition.operator}</span>{" "}
|
|
<span className="text-green-700">{condition.value}</span>
|
|
</p>
|
|
</div>
|
|
))}
|
|
{controlConditions.length > 3 && (
|
|
<p className="px-2 text-xs text-gray-500">외 {controlConditions.length - 3}개...</p>
|
|
)}
|
|
</div>
|
|
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
|
<div className="flex items-center gap-1">
|
|
<CheckCircle className="h-3 w-3 text-green-600" />
|
|
<span className="text-xs text-gray-600">충족 → 실행</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<XCircle className="h-3 w-3 text-red-600" />
|
|
<span className="text-xs text-gray-600">불만족 → 중단</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-500">조건 없음 (항상 실행)</p>
|
|
)}
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
|
<Edit className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 3. 액션 노드들 (우측) */}
|
|
<div className="flex flex-col items-center gap-3" style={{ width: "28%" }}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onEdit("actions")}
|
|
className="mb-2 flex items-center gap-2 self-end"
|
|
>
|
|
<Edit className="h-3 w-3" />
|
|
<span className="text-xs">전체 편집</span>
|
|
</Button>
|
|
|
|
{hasActions ? (
|
|
<div className="w-full space-y-3">
|
|
{Object.entries(actionGroups).map(([groupKey, actions]) => (
|
|
<ActionFlowCard
|
|
key={groupKey}
|
|
type={actions[0].actionType}
|
|
actions={actions}
|
|
getTableLabel={getTableLabel}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card className="w-full border-2 border-dashed border-gray-300 bg-gray-50">
|
|
<CardContent className="p-4 text-center">
|
|
<Zap className="mx-auto mb-2 h-6 w-6 text-gray-400" />
|
|
<p className="text-xs text-gray-500">액션 미설정</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 조건 불만족 시 중단 표시 (하단) */}
|
|
{hasConditions && (
|
|
<div
|
|
className="absolute bottom-0 flex items-center gap-2 rounded-lg border-2 border-red-300 bg-red-50 px-3 py-2"
|
|
style={{ left: "50%", transform: "translateX(-50%)" }}
|
|
>
|
|
<XCircle className="h-4 w-4 text-red-600" />
|
|
<span className="text-xs font-medium text-red-900">조건 불만족 → 실행 중단</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 통계 요약 */}
|
|
<Card className="border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-around text-center">
|
|
<div>
|
|
<p className="text-xs text-gray-600">소스</p>
|
|
<p className="text-lg font-bold text-blue-600">{hasSource ? 1 : 0}</p>
|
|
</div>
|
|
<div className="h-8 w-px bg-gray-300"></div>
|
|
<div>
|
|
<p className="text-xs text-gray-600">조건</p>
|
|
<p className="text-lg font-bold text-yellow-600">{controlConditions.length}</p>
|
|
</div>
|
|
<div className="h-8 w-px bg-gray-300"></div>
|
|
<div>
|
|
<p className="text-xs text-gray-600">액션</p>
|
|
<p className="text-lg font-bold text-green-600">{dataflowActions.length}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 액션 플로우 카드 컴포넌트
|
|
interface ActionFlowCardProps {
|
|
type: string;
|
|
actions: Array<{
|
|
actionType: string;
|
|
targetTable?: string;
|
|
name?: string;
|
|
fieldMappings?: any[];
|
|
}>;
|
|
getTableLabel: (tableName: string) => string;
|
|
}
|
|
|
|
const ActionFlowCard: React.FC<ActionFlowCardProps> = ({ type, actions, getTableLabel }) => {
|
|
const actionColors = {
|
|
insert: { bg: "bg-blue-50", border: "border-blue-300", text: "text-blue-900", icon: "text-blue-600" },
|
|
update: { bg: "bg-green-50", border: "border-green-300", text: "text-green-900", icon: "text-green-600" },
|
|
delete: { bg: "bg-red-50", border: "border-red-300", text: "text-red-900", icon: "text-red-600" },
|
|
upsert: { bg: "bg-purple-50", border: "border-purple-300", text: "text-purple-900", icon: "text-purple-600" },
|
|
};
|
|
|
|
const colors = actionColors[type as keyof typeof actionColors] || actionColors.insert;
|
|
const action = actions[0]; // 그룹당 1개만 표시
|
|
const displayName = action.targetTable ? getTableLabel(action.targetTable) : action.name || "액션";
|
|
const isTableLabel = action.targetTable && getTableLabel(action.targetTable) !== action.targetTable;
|
|
|
|
return (
|
|
<Card className={`border-2 ${colors.border} ${colors.bg}`}>
|
|
<CardContent className="p-3">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Zap className={`h-4 w-4 ${colors.icon}`} />
|
|
<span className={`text-sm font-semibold ${colors.text}`}>{type.toUpperCase()}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Database className="h-3 w-3 text-gray-500" />
|
|
<span className="truncate font-medium text-gray-900">{displayName}</span>
|
|
</div>
|
|
{isTableLabel && action.targetTable && (
|
|
<span className="ml-5 truncate text-xs text-gray-500">({action.targetTable})</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|