2025-09-26 01:28:51 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
|
|
|
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
|
|
import {
|
|
|
|
|
|
ChevronDown,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Settings2,
|
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
|
Save,
|
|
|
|
|
|
Play,
|
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
// API import (컬럼 로드는 중앙에서 관리)
|
2025-09-26 01:28:51 +09:00
|
|
|
|
|
|
|
|
|
|
// 타입 import
|
|
|
|
|
|
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
|
|
|
|
|
import { ActionGroup, SingleAction, FieldMapping } from "../types/redesigned";
|
|
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 import
|
|
|
|
|
|
import ActionConditionBuilder from "./ActionConfig/ActionConditionBuilder";
|
|
|
|
|
|
import FieldMappingCanvas from "./VisualMapping/FieldMappingCanvas";
|
|
|
|
|
|
|
|
|
|
|
|
interface MultiActionConfigStepProps {
|
|
|
|
|
|
fromTable?: TableInfo;
|
|
|
|
|
|
toTable?: TableInfo;
|
|
|
|
|
|
fromConnection?: Connection;
|
|
|
|
|
|
toConnection?: Connection;
|
2025-09-26 13:52:32 +09:00
|
|
|
|
// 컬럼 정보 (중앙에서 관리) 🔧 추가
|
|
|
|
|
|
fromColumns?: ColumnInfo[];
|
|
|
|
|
|
toColumns?: ColumnInfo[];
|
2025-09-26 01:28:51 +09:00
|
|
|
|
// 제어 조건 관련
|
|
|
|
|
|
controlConditions: any[];
|
|
|
|
|
|
onUpdateControlCondition: (index: number, condition: any) => void;
|
|
|
|
|
|
onDeleteControlCondition: (index: number) => void;
|
|
|
|
|
|
onAddControlCondition: () => void;
|
|
|
|
|
|
// 액션 그룹 관련
|
|
|
|
|
|
actionGroups: ActionGroup[];
|
2025-09-26 13:52:32 +09:00
|
|
|
|
groupsLogicalOperator?: "AND" | "OR";
|
2025-09-26 01:28:51 +09:00
|
|
|
|
onUpdateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
|
|
|
|
|
onDeleteActionGroup: (groupId: string) => void;
|
|
|
|
|
|
onAddActionGroup: () => void;
|
|
|
|
|
|
onAddActionToGroup: (groupId: string) => void;
|
|
|
|
|
|
onUpdateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
|
|
|
|
|
onDeleteActionFromGroup: (groupId: string, actionId: string) => void;
|
2025-09-26 13:52:32 +09:00
|
|
|
|
onSetGroupsLogicalOperator?: (operator: "AND" | "OR") => void;
|
2025-09-26 01:28:51 +09:00
|
|
|
|
// 필드 매핑 관련
|
|
|
|
|
|
fieldMappings: FieldMapping[];
|
|
|
|
|
|
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
|
|
|
|
|
onDeleteMapping: (mappingId: string) => void;
|
|
|
|
|
|
// 네비게이션
|
|
|
|
|
|
onNext: () => void;
|
|
|
|
|
|
onBack: () => void;
|
2025-09-26 13:52:32 +09:00
|
|
|
|
// 컬럼 로드 액션
|
|
|
|
|
|
onLoadColumns?: () => Promise<void>;
|
2025-09-26 01:28:51 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 🎯 4단계: 통합된 멀티 액션 설정
|
|
|
|
|
|
* - 제어 조건 설정
|
|
|
|
|
|
* - 여러 액션 그룹 관리
|
|
|
|
|
|
* - AND/OR 논리 연산자
|
|
|
|
|
|
* - 액션별 조건 설정
|
|
|
|
|
|
* - INSERT 액션 시 컬럼 매핑
|
|
|
|
|
|
*/
|
|
|
|
|
|
const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|
|
|
|
|
fromTable,
|
|
|
|
|
|
toTable,
|
|
|
|
|
|
fromConnection,
|
|
|
|
|
|
toConnection,
|
2025-09-26 13:52:32 +09:00
|
|
|
|
fromColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
|
|
|
|
|
|
toColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
|
2025-09-26 01:28:51 +09:00
|
|
|
|
controlConditions,
|
|
|
|
|
|
onUpdateControlCondition,
|
|
|
|
|
|
onDeleteControlCondition,
|
|
|
|
|
|
onAddControlCondition,
|
|
|
|
|
|
actionGroups,
|
2025-09-26 13:52:32 +09:00
|
|
|
|
groupsLogicalOperator = "AND",
|
2025-09-26 01:28:51 +09:00
|
|
|
|
onUpdateActionGroup,
|
|
|
|
|
|
onDeleteActionGroup,
|
|
|
|
|
|
onAddActionGroup,
|
|
|
|
|
|
onAddActionToGroup,
|
|
|
|
|
|
onUpdateActionInGroup,
|
|
|
|
|
|
onDeleteActionFromGroup,
|
2025-09-26 13:52:32 +09:00
|
|
|
|
onSetGroupsLogicalOperator,
|
2025-09-26 01:28:51 +09:00
|
|
|
|
fieldMappings,
|
|
|
|
|
|
onCreateMapping,
|
|
|
|
|
|
onDeleteMapping,
|
|
|
|
|
|
onNext,
|
|
|
|
|
|
onBack,
|
2025-09-26 13:52:32 +09:00
|
|
|
|
onLoadColumns,
|
2025-09-26 01:28:51 +09:00
|
|
|
|
}) => {
|
|
|
|
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
|
|
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
// 컬럼 로딩 상태 확인
|
|
|
|
|
|
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
|
2025-09-26 01:28:51 +09:00
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
// 컴포넌트 마운트 시 컬럼 로드
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isColumnsLoaded && fromConnection && toConnection && fromTable && toTable && onLoadColumns) {
|
|
|
|
|
|
console.log("🔄 MultiActionConfigStep: 컬럼 로드 시작");
|
|
|
|
|
|
onLoadColumns();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isColumnsLoaded, fromConnection?.id, toConnection?.id, fromTable?.tableName, toTable?.tableName]);
|
2025-09-26 01:28:51 +09:00
|
|
|
|
|
|
|
|
|
|
// 그룹 확장/축소 토글
|
|
|
|
|
|
const toggleGroupExpansion = (groupId: string) => {
|
|
|
|
|
|
setExpandedGroups((prev) => {
|
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
|
if (newSet.has(groupId)) {
|
|
|
|
|
|
newSet.delete(groupId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newSet.add(groupId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return newSet;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 액션 타입별 아이콘
|
|
|
|
|
|
const getActionTypeIcon = (actionType: string) => {
|
|
|
|
|
|
switch (actionType) {
|
|
|
|
|
|
case "insert":
|
|
|
|
|
|
return "➕";
|
|
|
|
|
|
case "update":
|
|
|
|
|
|
return "✏️";
|
|
|
|
|
|
case "delete":
|
|
|
|
|
|
return "🗑️";
|
|
|
|
|
|
case "upsert":
|
|
|
|
|
|
return "🔄";
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "⚙️";
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 논리 연산자별 색상
|
|
|
|
|
|
const getLogicalOperatorColor = (operator: string) => {
|
|
|
|
|
|
switch (operator) {
|
|
|
|
|
|
case "AND":
|
|
|
|
|
|
return "bg-blue-100 text-blue-800";
|
|
|
|
|
|
case "OR":
|
|
|
|
|
|
return "bg-orange-100 text-orange-800";
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "bg-gray-100 text-gray-800";
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// INSERT 액션이 있는지 확인
|
|
|
|
|
|
const hasInsertActions = actionGroups.some((group) =>
|
|
|
|
|
|
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
// 탭 정보 (컬럼 매핑 탭 제거)
|
2025-09-26 01:28:51 +09:00
|
|
|
|
const tabs = [
|
|
|
|
|
|
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
|
|
|
|
|
|
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<CardHeader className="pb-2">
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
|
<Settings2 className="h-5 w-5" />
|
|
|
|
|
|
4단계: 액션 및 매핑 설정
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">제어 조건, 액션 그룹, 필드 매핑을 설정하세요</p>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<CardContent className="flex h-full flex-col p-4">
|
|
|
|
|
|
{/* 탭 헤더 */}
|
|
|
|
|
|
<div className="mb-4 flex border-b">
|
|
|
|
|
|
{tabs.map((tab) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={tab.id}
|
|
|
|
|
|
onClick={() => setActiveTab(tab.id)}
|
|
|
|
|
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
|
|
|
|
|
|
activeTab === tab.id
|
|
|
|
|
|
? "border-primary text-primary border-b-2"
|
|
|
|
|
|
: "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" && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* 제어 조건 섹션 */}
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<h3 className="text-lg font-medium">제어 조건</h3>
|
|
|
|
|
|
<Button onClick={onAddControlCondition} size="sm" className="flex items-center gap-2">
|
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
조건 추가
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{controlConditions.length === 0 ? (
|
|
|
|
|
|
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center">
|
|
|
|
|
|
<div className="text-muted-foreground">
|
|
|
|
|
|
<AlertTriangle className="mx-auto mb-2 h-8 w-8" />
|
|
|
|
|
|
<p className="mb-2">제어 조건이 없습니다</p>
|
|
|
|
|
|
<p className="text-sm">조건을 추가하면 해당 조건이 충족될 때만 액션이 실행됩니다</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{controlConditions.map((condition, index) => (
|
|
|
|
|
|
<div key={index} className="flex items-center gap-3 rounded-md border p-3">
|
|
|
|
|
|
<span className="text-muted-foreground text-sm">조건 {index + 1}</span>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
{/* 여기에 조건 편집 컴포넌트 추가 */}
|
|
|
|
|
|
<div className="text-muted-foreground text-sm">조건 설정: {JSON.stringify(condition)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => onDeleteControlCondition(index)}>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{activeTab === "actions" && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* 액션 그룹 헤더 */}
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<h3 className="text-lg font-medium">액션 그룹</h3>
|
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
|
{actionGroups.filter((g) => g.isEnabled).length}개 활성화
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button onClick={onAddActionGroup} size="sm" className="flex items-center gap-2">
|
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
그룹 추가
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
{/* 그룹 간 논리 연산자 선택 */}
|
|
|
|
|
|
{actionGroups.length > 1 && (
|
|
|
|
|
|
<div className="rounded-md border bg-blue-50 p-3">
|
|
|
|
|
|
<div className="mb-2 flex items-center gap-2">
|
|
|
|
|
|
<h4 className="text-sm font-medium text-blue-900">그룹 간 실행 조건</h4>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
id="groups-and"
|
|
|
|
|
|
name="groups-operator"
|
|
|
|
|
|
checked={groupsLogicalOperator === "AND"}
|
|
|
|
|
|
onChange={() => onSetGroupsLogicalOperator?.("AND")}
|
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label htmlFor="groups-and" className="text-sm text-blue-800">
|
|
|
|
|
|
<span className="font-medium">AND</span> - 모든 그룹의 조건이 참일 때 실행
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
id="groups-or"
|
|
|
|
|
|
name="groups-operator"
|
|
|
|
|
|
checked={groupsLogicalOperator === "OR"}
|
|
|
|
|
|
onChange={() => onSetGroupsLogicalOperator?.("OR")}
|
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label htmlFor="groups-or" className="text-sm text-blue-800">
|
|
|
|
|
|
<span className="font-medium">OR</span> - 하나 이상의 그룹 조건이 참일 때 실행
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-09-26 01:28:51 +09:00
|
|
|
|
{/* 액션 그룹 목록 */}
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{actionGroups.map((group, groupIndex) => (
|
2025-09-26 13:52:32 +09:00
|
|
|
|
<div key={group.id}>
|
|
|
|
|
|
{/* 그룹 간 논리 연산자 표시 (첫 번째 그룹 제외) */}
|
|
|
|
|
|
{groupIndex > 0 && (
|
|
|
|
|
|
<div className="my-2 flex items-center justify-center">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`rounded px-2 py-1 text-xs font-medium ${
|
|
|
|
|
|
groupsLogicalOperator === "AND"
|
|
|
|
|
|
? "bg-green-100 text-green-800"
|
|
|
|
|
|
: "bg-orange-100 text-orange-800"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{groupsLogicalOperator}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="bg-card rounded-lg border">
|
|
|
|
|
|
{/* 그룹 헤더 */}
|
|
|
|
|
|
<Collapsible
|
|
|
|
|
|
open={expandedGroups.has(group.id)}
|
|
|
|
|
|
onOpenChange={() => toggleGroupExpansion(group.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CollapsibleTrigger asChild>
|
|
|
|
|
|
<div className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
{expandedGroups.has(group.id) ? (
|
|
|
|
|
|
<ChevronDown className="h-4 w-4" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={group.name}
|
|
|
|
|
|
onChange={(e) => onUpdateActionGroup(group.id, { name: e.target.value })}
|
|
|
|
|
|
className="h-8 w-40"
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Badge className={getLogicalOperatorColor(group.logicalOperator)}>
|
|
|
|
|
|
{group.logicalOperator}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge variant={group.isEnabled ? "default" : "secondary"}>
|
|
|
|
|
|
{group.actions.length}개 액션
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-26 01:28:51 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
2025-09-26 13:52:32 +09:00
|
|
|
|
{/* 그룹 논리 연산자 선택 */}
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={group.logicalOperator}
|
|
|
|
|
|
onValueChange={(value: "AND" | "OR") =>
|
|
|
|
|
|
onUpdateActionGroup(group.id, { logicalOperator: value })
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-8 w-20" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="AND">AND</SelectItem>
|
|
|
|
|
|
<SelectItem value="OR">OR</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 그룹 활성화/비활성화 */}
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
checked={group.isEnabled}
|
|
|
|
|
|
onCheckedChange={(checked) => onUpdateActionGroup(group.id, { isEnabled: checked })}
|
2025-09-26 01:28:51 +09:00
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
/>
|
2025-09-26 13:52:32 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 그룹 삭제 */}
|
|
|
|
|
|
{actionGroups.length > 1 && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onDeleteActionGroup(group.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-09-26 01:28:51 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-26 13:52:32 +09:00
|
|
|
|
</CollapsibleTrigger>
|
2025-09-26 01:28:51 +09:00
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
{/* 그룹 내용 */}
|
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
|
<div className="bg-muted/20 border-t p-4">
|
|
|
|
|
|
{/* 액션 추가 버튼 */}
|
|
|
|
|
|
<div className="mb-4 flex justify-end">
|
2025-09-26 01:28:51 +09:00
|
|
|
|
<Button
|
2025-09-26 13:52:32 +09:00
|
|
|
|
variant="outline"
|
2025-09-26 01:28:51 +09:00
|
|
|
|
size="sm"
|
2025-09-26 13:52:32 +09:00
|
|
|
|
onClick={() => onAddActionToGroup(group.id)}
|
|
|
|
|
|
className="flex items-center gap-2"
|
2025-09-26 01:28:51 +09:00
|
|
|
|
>
|
2025-09-26 13:52:32 +09:00
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
액션 추가
|
2025-09-26 01:28:51 +09:00
|
|
|
|
</Button>
|
2025-09-26 13:52:32 +09:00
|
|
|
|
</div>
|
2025-09-26 01:28:51 +09:00
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
{/* 액션 목록 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{group.actions.map((action, actionIndex) => (
|
|
|
|
|
|
<div key={action.id} className="rounded-md border bg-white p-3">
|
|
|
|
|
|
{/* 액션 헤더 */}
|
|
|
|
|
|
<div className="mb-3 flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<span className="text-lg">{getActionTypeIcon(action.actionType)}</span>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={action.name}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
onUpdateActionInGroup(group.id, action.id, { name: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
className="h-8 w-32"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={action.actionType}
|
|
|
|
|
|
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
|
|
|
|
|
onUpdateActionInGroup(group.id, action.id, { actionType: value })
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-8 w-24">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="insert">INSERT</SelectItem>
|
|
|
|
|
|
<SelectItem value="update">UPDATE</SelectItem>
|
|
|
|
|
|
<SelectItem value="delete">DELETE</SelectItem>
|
|
|
|
|
|
<SelectItem value="upsert">UPSERT</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
checked={action.isEnabled}
|
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
|
onUpdateActionInGroup(group.id, action.id, { isEnabled: checked })
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{group.actions.length > 1 && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => onDeleteActionFromGroup(group.id, action.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-09-26 01:28:51 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
{/* 액션 조건 설정 */}
|
|
|
|
|
|
{isColumnsLoaded ? (
|
|
|
|
|
|
<ActionConditionBuilder
|
|
|
|
|
|
actionType={action.actionType}
|
|
|
|
|
|
fromColumns={fromColumns}
|
|
|
|
|
|
toColumns={toColumns}
|
|
|
|
|
|
conditions={action.conditions}
|
|
|
|
|
|
fieldMappings={(() => {
|
|
|
|
|
|
// 필드값 설정용: FieldValueMapping 타입만 필터링
|
|
|
|
|
|
const fieldValueMappings = (action.fieldMappings || []).filter(
|
|
|
|
|
|
(mapping) =>
|
|
|
|
|
|
mapping.valueType && // valueType이 있고
|
|
|
|
|
|
!mapping.fromField && // fromField가 없고
|
|
|
|
|
|
!mapping.toField, // toField가 없으면 FieldValueMapping
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
console.log("📋 ActionConditionBuilder에 전달되는 필드값 설정:", {
|
|
|
|
|
|
allMappings: action.fieldMappings,
|
|
|
|
|
|
filteredFieldValueMappings: fieldValueMappings,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return fieldValueMappings;
|
|
|
|
|
|
})()}
|
|
|
|
|
|
columnMappings={
|
|
|
|
|
|
// 컬럼 매핑용: FieldMapping 타입만 필터링
|
|
|
|
|
|
(action.fieldMappings || []).filter(
|
|
|
|
|
|
(mapping) =>
|
|
|
|
|
|
mapping.fromField &&
|
|
|
|
|
|
mapping.toField &&
|
|
|
|
|
|
mapping.fromField.columnName &&
|
|
|
|
|
|
mapping.toField.columnName,
|
|
|
|
|
|
)
|
2025-09-26 01:28:51 +09:00
|
|
|
|
}
|
2025-09-26 13:52:32 +09:00
|
|
|
|
onConditionsChange={(conditions) =>
|
|
|
|
|
|
onUpdateActionInGroup(group.id, action.id, { conditions })
|
|
|
|
|
|
}
|
|
|
|
|
|
onFieldMappingsChange={(newFieldMappings) => {
|
|
|
|
|
|
// 필드값 설정만 업데이트, 컬럼 매핑은 유지
|
|
|
|
|
|
const existingColumnMappings = (action.fieldMappings || []).filter(
|
|
|
|
|
|
(mapping) =>
|
|
|
|
|
|
mapping.fromField &&
|
|
|
|
|
|
mapping.toField &&
|
|
|
|
|
|
mapping.fromField.columnName &&
|
|
|
|
|
|
mapping.toField.columnName,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
console.log("🔄 필드값 설정 업데이트:", {
|
|
|
|
|
|
existingColumnMappings,
|
|
|
|
|
|
newFieldMappings,
|
|
|
|
|
|
combined: [...existingColumnMappings, ...newFieldMappings],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUpdateActionInGroup(group.id, action.id, {
|
|
|
|
|
|
fieldMappings: [...existingColumnMappings, ...newFieldMappings],
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
2025-09-26 01:28:51 +09:00
|
|
|
|
/>
|
2025-09-26 13:52:32 +09:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="text-muted-foreground flex items-center justify-center py-4">
|
|
|
|
|
|
컬럼 정보를 불러오는 중...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* INSERT 액션일 때만 필드 매핑 UI 표시 */}
|
|
|
|
|
|
{action.actionType === "insert" && isColumnsLoaded && (
|
|
|
|
|
|
<div className="mt-4 space-y-3">
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<h5 className="text-sm font-medium">필드 매핑</h5>
|
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
|
{action.fieldMappings?.length || 0}개 매핑
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 컬럼 매핑 캔버스 */}
|
|
|
|
|
|
<div className="rounded-lg border bg-white p-3">
|
|
|
|
|
|
<FieldMappingCanvas
|
|
|
|
|
|
fromFields={fromColumns}
|
|
|
|
|
|
toFields={toColumns}
|
|
|
|
|
|
mappings={
|
|
|
|
|
|
// 컬럼 매핑만 FieldMappingCanvas에 전달
|
|
|
|
|
|
(action.fieldMappings || []).filter(
|
|
|
|
|
|
(mapping) =>
|
|
|
|
|
|
mapping.fromField &&
|
|
|
|
|
|
mapping.toField &&
|
|
|
|
|
|
mapping.fromField.columnName &&
|
|
|
|
|
|
mapping.toField.columnName,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onCreateMapping={(fromField, toField) => {
|
|
|
|
|
|
const newMapping = {
|
|
|
|
|
|
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
|
|
|
|
|
fromField,
|
|
|
|
|
|
toField,
|
|
|
|
|
|
isValid: true,
|
|
|
|
|
|
validationMessage: undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 기존 필드값 설정은 유지하고 새 컬럼 매핑만 추가
|
|
|
|
|
|
const existingFieldValueMappings = (action.fieldMappings || []).filter(
|
|
|
|
|
|
(mapping) =>
|
|
|
|
|
|
mapping.valueType && // valueType이 있고
|
|
|
|
|
|
!mapping.fromField && // fromField가 없고
|
|
|
|
|
|
!mapping.toField, // toField가 없으면 FieldValueMapping
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const existingColumnMappings = (action.fieldMappings || []).filter(
|
|
|
|
|
|
(mapping) =>
|
|
|
|
|
|
mapping.fromField &&
|
|
|
|
|
|
mapping.toField &&
|
|
|
|
|
|
mapping.fromField.columnName &&
|
|
|
|
|
|
mapping.toField.columnName,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
onUpdateActionInGroup(group.id, action.id, {
|
|
|
|
|
|
fieldMappings: [
|
|
|
|
|
|
...existingFieldValueMappings,
|
|
|
|
|
|
...existingColumnMappings,
|
|
|
|
|
|
newMapping,
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDeleteMapping={(mappingId) => {
|
|
|
|
|
|
// 컬럼 매핑만 삭제하고 필드값 설정은 유지
|
|
|
|
|
|
const remainingMappings = (action.fieldMappings || []).filter(
|
|
|
|
|
|
(mapping) => mapping.id !== mappingId,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
onUpdateActionInGroup(group.id, action.id, {
|
|
|
|
|
|
fieldMappings: remainingMappings,
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 매핑되지 않은 필드 처리 옵션 */}
|
|
|
|
|
|
<div className="rounded-md border bg-yellow-50 p-3">
|
|
|
|
|
|
<h6 className="mb-2 flex items-center gap-1 text-xs font-medium text-yellow-800">
|
|
|
|
|
|
<AlertTriangle className="h-3 w-3" />
|
|
|
|
|
|
매핑되지 않은 필드 처리
|
|
|
|
|
|
</h6>
|
|
|
|
|
|
<div className="space-y-2 text-xs">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
id={`empty-${action.id}`}
|
|
|
|
|
|
name={`unmapped-${action.id}`}
|
|
|
|
|
|
defaultChecked
|
|
|
|
|
|
className="h-3 w-3"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label htmlFor={`empty-${action.id}`} className="text-yellow-700">
|
|
|
|
|
|
비워두기 (NULL 값)
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
id={`default-${action.id}`}
|
|
|
|
|
|
name={`unmapped-${action.id}`}
|
|
|
|
|
|
className="h-3 w-3"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label htmlFor={`default-${action.id}`} className="text-yellow-700">
|
|
|
|
|
|
기본값 사용
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
id={`skip-${action.id}`}
|
|
|
|
|
|
name={`unmapped-${action.id}`}
|
|
|
|
|
|
className="h-3 w-3"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label htmlFor={`skip-${action.id}`} className="text-yellow-700">
|
|
|
|
|
|
필드 제외
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-09-26 01:28:51 +09:00
|
|
|
|
</div>
|
2025-09-26 13:52:32 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2025-09-26 01:28:51 +09:00
|
|
|
|
|
2025-09-26 13:52:32 +09:00
|
|
|
|
{/* 그룹 로직 설명 */}
|
|
|
|
|
|
<div className="mt-4 rounded-md bg-blue-50 p-3">
|
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 text-blue-600" />
|
|
|
|
|
|
<div className="text-sm">
|
|
|
|
|
|
<div className="font-medium text-blue-900">{group.logicalOperator} 조건 그룹</div>
|
|
|
|
|
|
<div className="text-blue-700">
|
|
|
|
|
|
{group.logicalOperator === "AND"
|
|
|
|
|
|
? "이 그룹의 모든 액션이 실행 가능한 조건일 때만 실행됩니다."
|
|
|
|
|
|
: "이 그룹의 액션 중 하나라도 실행 가능한 조건이면 해당 액션만 실행됩니다."}
|
|
|
|
|
|
</div>
|
2025-09-26 01:28:51 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-26 13:52:32 +09:00
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
</div>
|
2025-09-26 01:28:51 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 하단 네비게이션 */}
|
|
|
|
|
|
<div className="flex-shrink-0 border-t pt-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" />
|
|
|
|
|
|
이전
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="text-muted-foreground text-sm">
|
|
|
|
|
|
{actionGroups.filter((g) => g.isEnabled).length}개 그룹, 총{" "}
|
|
|
|
|
|
{actionGroups.reduce((sum, g) => sum + g.actions.filter((a) => a.isEnabled).length, 0)}개 액션
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Button onClick={onNext} className="flex items-center gap-2">
|
|
|
|
|
|
<Save className="h-4 w-4" />
|
|
|
|
|
|
저장
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MultiActionConfigStep;
|