제어관리 수정

This commit is contained in:
kjs 2025-10-02 11:12:45 +09:00
parent 3f76d16afe
commit db25b0435f
6 changed files with 539 additions and 160 deletions

View File

@ -24,13 +24,13 @@ export default function DataFlowPage() {
// 단계별 제목과 설명
const stepConfig = {
list: {
title: "데이터 흐름 관계도 관리",
description: "생성된 관계도들을 확인하고 관리하세요",
title: "데이터 흐름 제어 관리",
description: "생성된 제어들을 확인하고 관리하세요",
icon: "📊",
},
design: {
title: "새 관계도 설계",
description: "테이블 간 데이터 관계를 시각적으로 설계하세요",
title: "새 제어 설계",
description: "테이블 간 데이터 제어를 시각적으로 설계하세요",
icon: "🎨",
},
};
@ -62,7 +62,7 @@ export default function DataFlowPage() {
};
const handleSave = (relationships: TableRelationship[]) => {
console.log("저장된 관계:", relationships);
console.log("저장된 제어:", relationships);
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
setTimeout(() => {
goToStep("list");
@ -71,28 +71,28 @@ export default function DataFlowPage() {
}, 0);
};
// 관계도 수정 핸들러
// 제어 수정 핸들러
const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => {
if (diagram) {
// 기존 관계도 수정 - 저장된 관계 정보 로드
// 기존 제어 수정 - 저장된 제어 정보 로드
try {
console.log("📖 관계도 수정 모드:", diagram);
console.log("📖 제어 수정 모드:", diagram);
// 저장된 관계 정보 로드
// 저장된 제어 정보 로드
const relationshipData = await loadDataflowRelationship(diagram.diagramId);
console.log("✅ 관계 정보 로드 완료:", relationshipData);
console.log("✅ 제어 정보 로드 완료:", relationshipData);
setEditingDiagram(diagram);
setLoadedRelationshipData(relationshipData);
goToNextStep("design");
toast.success(`"${diagram.diagramName}" 관계를 불러왔습니다.`);
toast.success(`"${diagram.diagramName}" 제어를 불러왔습니다.`);
} catch (error: any) {
console.error("❌ 관계 정보 로드 실패:", error);
toast.error(error.message || "관계 정보를 불러오는데 실패했습니다.");
console.error("❌ 제어 정보 로드 실패:", error);
toast.error(error.message || "제어 정보를 불러오는데 실패했습니다.");
}
} else {
// 새 관계도 생성 - 현재 페이지에서 처리
// 새 제어 생성 - 현재 페이지에서 처리
setEditingDiagram(null);
setLoadedRelationshipData(null);
goToNextStep("design");
@ -101,21 +101,21 @@ export default function DataFlowPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto space-y-4 p-4">
<div className="mx-auto space-y-4 px-5 py-4">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div>
{/* 단계별 내용 */}
<div className="space-y-6">
{/* 관계도 목록 단계 */}
{/* 제어 목록 단계 */}
{currentStep === "list" && <DataFlowList onDesignDiagram={handleDesignDiagram} />}
{/* 관계도 설계 단계 - 🎨 새로운 UI 사용 */}
{/* 제어 설계 단계 - 🎨 새로운 UI 사용 */}
{currentStep === "design" && (
<DataConnectionDesigner
onClose={() => {

View File

@ -68,7 +68,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
tables.push(relationships.toTable.tableName);
}
// 관계 수 계산 (actionGroups 기준)
// 제어 수 계산 (actionGroups 기준)
const actionGroups = relationships.actionGroups || [];
const relationshipCount = actionGroups.reduce((count: number, group: any) => {
return count + (group.actions?.length || 0);
@ -79,7 +79,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
diagramName: diagram.diagram_name,
connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용
relationshipType: "multi-relationship", // 다중 관계 타입
relationshipType: "multi-relationship", // 다중 제어 타입
relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정
tableCount: tables.length,
tables: tables,
@ -96,14 +96,14 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
setTotal(response.pagination.total || 0);
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
} catch (error) {
console.error("관계 목록 조회 실패", error);
toast.error("관계 목록을 불러오는데 실패했습니다.");
console.error("제어 목록 조회 실패", error);
toast.error("제어 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [currentPage, searchTerm, companyCode]);
// 관계 목록 로드
// 제어 목록 로드
useEffect(() => {
loadDiagrams();
}, [loadDiagrams]);
@ -130,13 +130,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
undefined,
user?.userId || "SYSTEM",
);
toast.success(`관계가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
toast.success(`제어가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
// 목록 새로고침
await loadDiagrams();
} catch (error) {
console.error("관계 복사 실패:", error);
toast.error("관계 복사에 실패했습니다.");
console.error("제어 복사 실패:", error);
toast.error("제어 복사에 실패했습니다.");
} finally {
setLoading(false);
setShowCopyModal(false);
@ -151,13 +151,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
try {
setLoading(true);
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
toast.success(`관계가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
toast.success(`제어가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
// 목록 새로고침
await loadDiagrams();
} catch (error) {
console.error("관계 삭제 실패:", error);
toast.error("관계 삭제에 실패했습니다.");
console.error("제어 삭제 실패:", error);
toast.error("제어 삭제에 실패했습니다.");
} finally {
setLoading(false);
setShowDeleteModal(false);
@ -181,7 +181,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="관계명, 테이블명으로 검색..."
placeholder="제어명, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
@ -189,17 +189,17 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
</div>
</div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
<Plus className="mr-2 h-4 w-4" />
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 관계 목록 테이블 */}
{/* 제어 목록 테이블 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center">
<Network className="mr-2 h-5 w-5" />
({total})
({total})
</span>
</CardTitle>
</CardHeader>
@ -207,10 +207,10 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
</TableRow>
@ -284,8 +284,8 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
{diagrams.length === 0 && (
<div className="py-8 text-center text-gray-500">
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> .</div>
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> .</div>
</div>
)}
</CardContent>
@ -320,11 +320,11 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle> </DialogTitle>
<DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br />
(1), (2), (3)... .
(1), (2), (3)... .
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -342,12 +342,12 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-red-600"> </DialogTitle>
<DialogTitle className="text-red-600"> </DialogTitle>
<DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br />
<span className="font-medium text-red-600">
, .
, .
</span>
</DialogDescription>
</DialogHeader>

View File

@ -629,17 +629,17 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
console.log("🧹 데이터 저장 타입으로 변경 - 기존 외부호출 설정 정리");
try {
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
// 기존 외부호출 설정이 있는지 확인하고 삭제 또는 비활성화
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
company_code: "*",
is_active: "Y",
});
const existingConfig = existingConfigs.data?.find(
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정")
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정"),
);
if (existingConfig) {
console.log("🗑️ 기존 외부호출 설정 비활성화:", existingConfig.id);
// 설정을 비활성화 (삭제하지 않고 is_active를 'N'으로 변경)
@ -669,22 +669,22 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
};
let configResult;
if (diagramId) {
// 수정 모드: 기존 설정이 있는지 확인하고 업데이트 또는 생성
console.log("🔄 수정 모드 - 외부호출 설정 처리");
try {
// 먼저 기존 설정 조회 시도
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
company_code: "*",
is_active: "Y",
});
const existingConfig = existingConfigs.data?.find(
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정")
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정"),
);
if (existingConfig) {
// 기존 설정 업데이트
console.log("📝 기존 외부호출 설정 업데이트:", existingConfig.id);
@ -801,12 +801,12 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
{/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
<div className="flex h-[calc(100vh-200px)] min-h-[700px] overflow-hidden">
{/* 좌측 패널 (30%) - 항상 표시 */}
<div className="flex w-[30%] flex-col border-r bg-white">
<div className="flex w-[20%] flex-col border-r bg-white">
<LeftPanel state={state} actions={actions} />
</div>
{/* 우측 패널 (70%) */}
<div className="flex w-[70%] flex-col bg-gray-50">
{/* 우측 패널 (80%) */}
<div className="flex w-[80%] flex-col bg-gray-50">
<RightPanel key={state.connectionType} state={state} actions={actions} />
</div>
</div>

View File

@ -5,7 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Settings, CheckCircle } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft, Settings, CheckCircle, Eye } from "lucide-react";
// 타입 import
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
@ -14,6 +15,7 @@ import { getColumnsFromConnection } from "@/lib/api/multiConnection";
// 컴포넌트 import
import ActionConditionBuilder from "./ActionConfig/ActionConditionBuilder";
import { DataflowVisualization } from "./DataflowVisualization";
interface ActionConfigStepProps {
state: DataConnectionState;
@ -78,7 +80,8 @@ const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
const canComplete =
actionType &&
(actionType === "insert" || (actionConditions.length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
(actionType === "insert" ||
((actionConditions || []).length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
return (
<>
@ -89,106 +92,137 @@ const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
</CardTitle>
</CardHeader>
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
{/* 액션 타입 선택 */}
<div className="space-y-3">
<h3 className="text-lg font-semibold"> </h3>
<Select value={actionType} onValueChange={actions.setActionType}>
<SelectTrigger>
<SelectValue placeholder="액션 타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{actionTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<div className="flex w-full items-center justify-between">
<div>
<span className="font-medium">{type.label}</span>
<p className="text-muted-foreground text-xs">{type.description}</p>
<CardContent className="flex h-full flex-col overflow-hidden p-0">
<Tabs defaultValue="config" className="flex h-full flex-col">
<div className="flex-shrink-0 border-b px-4 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="config" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="visualization" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
{/* 액션 설정 탭 */}
<TabsContent value="config" className="mt-0 flex-1 overflow-y-auto p-4">
<div className="space-y-6">
{/* 액션 타입 선택 */}
<div className="space-y-3">
<h3 className="text-lg font-semibold"> </h3>
<Select value={actionType} onValueChange={actions.setActionType}>
<SelectTrigger>
<SelectValue placeholder="액션 타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{actionTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<div className="flex w-full items-center justify-between">
<div>
<span className="font-medium">{type.label}</span>
<p className="text-muted-foreground text-xs">{type.description}</p>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{actionType && (
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-primary">
{actionTypes.find((t) => t.value === actionType)?.label}
</Badge>
<span className="text-sm">{actionTypes.find((t) => t.value === actionType)?.description}</span>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{actionType && (
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-primary">
{actionTypes.find((t) => t.value === actionType)?.label}
</Badge>
<span className="text-sm">{actionTypes.find((t) => t.value === actionType)?.description}</span>
)}
</div>
</div>
)}
</div>
{/* 상세 조건 설정 */}
{actionType && !isLoading && fromColumns.length > 0 && toColumns.length > 0 && (
<ActionConditionBuilder
actionType={actionType}
fromColumns={fromColumns}
toColumns={toColumns}
conditions={actionConditions}
fieldMappings={fieldMappings}
onConditionsChange={(conditions) => {
// 액션 조건 배열 전체 업데이트
actions.setActionConditions(conditions);
}}
onFieldMappingsChange={setFieldMappings}
/>
)}
{/* 상세 조건 설정 */}
{actionType && !isLoading && fromColumns.length > 0 && toColumns.length > 0 && (
<ActionConditionBuilder
actionType={actionType}
fromColumns={fromColumns}
toColumns={toColumns}
conditions={actionConditions || []}
fieldMappings={fieldMappings}
onConditionsChange={(conditions) => {
// 액션 조건 배열 전체 업데이트
actions.setActionConditions(conditions);
}}
onFieldMappingsChange={setFieldMappings}
/>
)}
{/* 로딩 상태 */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> ...</div>
</div>
)}
{/* 로딩 상태 */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> ...</div>
</div>
)}
{/* INSERT 액션 안내 */}
{actionType === "insert" && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<h4 className="mb-2 text-sm font-medium text-green-800">INSERT </h4>
<p className="text-sm text-green-700">
INSERT . .
</p>
</div>
)}
{/* INSERT 액션 안내 */}
{actionType === "insert" && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<h4 className="mb-2 text-sm font-medium text-green-800">INSERT </h4>
<p className="text-sm text-green-700">
INSERT . .
</p>
</div>
)}
{/* 액션 요약 */}
{actionType && (
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="mb-3 text-sm font-medium"> </h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span> :</span>
<Badge variant="outline">{actionType.toUpperCase()}</Badge>
</div>
{actionType !== "insert" && (
<>
<div className="flex justify-between text-sm">
<span> :</span>
<span className="text-muted-foreground">
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
</span>
</div>
{actionType !== "delete" && (
{/* 액션 요약 */}
{actionType && (
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="mb-3 text-sm font-medium"> </h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span> :</span>
<span className="text-muted-foreground">
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
</span>
<span> :</span>
<Badge variant="outline">{actionType.toUpperCase()}</Badge>
</div>
)}
</>
{actionType !== "insert" && (
<>
<div className="flex justify-between text-sm">
<span> :</span>
<span className="text-muted-foreground">
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
</span>
</div>
{actionType !== "delete" && (
<div className="flex justify-between text-sm">
<span> :</span>
<span className="text-muted-foreground">
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
</span>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
</div>
)}
</TabsContent>
{/* 흐름 미리보기 탭 */}
<TabsContent value="visualization" className="mt-0 flex-1 overflow-y-auto">
<DataflowVisualization
state={state}
onEdit={(step) => {
// 편집 버튼 클릭 시 해당 단계로 이동하는 로직 추가 가능
console.log(`편집 요청: ${step}`);
}}
/>
</TabsContent>
</Tabs>
{/* 하단 네비게이션 */}
<div className="border-t pt-4">
<div className="flex-shrink-0 border-t bg-white p-4">
<div className="flex items-center justify-between">
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />

View File

@ -0,0 +1,321 @@
"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>
);
};

View File

@ -21,6 +21,7 @@ import {
Save,
Play,
AlertTriangle,
Eye,
} from "lucide-react";
import { toast } from "sonner";
@ -28,6 +29,9 @@ import { toast } from "sonner";
// 타입 import
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
// 컴포넌트 import
import { DataflowVisualization } from "./DataflowVisualization";
import { ActionGroup, SingleAction, FieldMapping } from "../types/redesigned";
// 컴포넌트 import
@ -104,7 +108,7 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
onLoadColumns,
}) => {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
const [activeTab, setActiveTab] = useState<"control" | "actions" | "visualization">("control"); // 현재 활성 탭
// 컬럼 로딩 상태 확인
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
@ -163,10 +167,11 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
);
// 탭 정보 (컬럼 매핑 탭 제거)
// 탭 정보 (흐름 미리보기 추가)
const tabs = [
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
{ id: "control" as const, label: "제어 조건", description: "전체 제어 실행 조건" },
{ id: "actions" as const, label: "액션 설정", description: "액션 그룹 및 실행 조건" },
{ id: "visualization" as const, label: "흐름 미리보기", description: "전체 데이터 흐름을 한눈에 확인" },
];
return (
@ -192,27 +197,16 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
: "text-muted-foreground hover:text-foreground"
}`}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
{tab.id === "actions" && (
<Badge variant="outline" className="ml-1 text-xs">
{actionGroups.filter((g) => g.isEnabled).length}
</Badge>
)}
{tab.id === "mapping" && hasInsertActions && (
<Badge variant="outline" className="ml-1 text-xs">
{fieldMappings.length}
</Badge>
)}
</button>
))}
</div>
{/* 탭 설명 */}
<div className="bg-muted/30 mb-4 rounded-md p-3">
<p className="text-muted-foreground text-sm">{tabs.find((tab) => tab.id === activeTab)?.description}</p>
</div>
{/* 탭별 컨텐츠 */}
<div className="min-h-0 flex-1 overflow-y-auto">
{activeTab === "control" && (
@ -671,6 +665,36 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
</div>
</div>
)}
{activeTab === "visualization" && (
<DataflowVisualization
state={{
fromTable,
toTable,
fromConnection,
toConnection,
fromColumns,
toColumns,
controlConditions,
dataflowActions: actionGroups.flatMap((group) =>
group.actions
.filter((action) => action.isEnabled)
.map((action) => ({
...action,
targetTable: toTable?.tableName || "",
})),
),
}}
onEdit={(step) => {
// 편집 버튼 클릭 시 해당 탭으로 이동
if (step === "conditions") {
setActiveTab("control");
} else if (step === "actions") {
setActiveTab("actions");
}
}}
/>
)}
</div>
{/* 하단 네비게이션 */}