ERP-node/frontend/components/dataflow/connection/redesigned/RightPanel/MultiActionConfigStep.tsx

572 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
// API import
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
// 타입 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;
// 제어 조건 관련
controlConditions: any[];
onUpdateControlCondition: (index: number, condition: any) => void;
onDeleteControlCondition: (index: number) => void;
onAddControlCondition: () => void;
// 액션 그룹 관련
actionGroups: ActionGroup[];
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;
// 필드 매핑 관련
fieldMappings: FieldMapping[];
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
onDeleteMapping: (mappingId: string) => void;
// 네비게이션
onNext: () => void;
onBack: () => void;
}
/**
* 🎯 4단계: 통합된 멀티 액션 설정
* - 제어 조건 설정
* - 여러 액션 그룹 관리
* - AND/OR 논리 연산자
* - 액션별 조건 설정
* - INSERT 액션 시 컬럼 매핑
*/
const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
fromTable,
toTable,
fromConnection,
toConnection,
controlConditions,
onUpdateControlCondition,
onDeleteControlCondition,
onAddControlCondition,
actionGroups,
onUpdateActionGroup,
onDeleteActionGroup,
onAddActionGroup,
onAddActionToGroup,
onUpdateActionInGroup,
onDeleteActionFromGroup,
fieldMappings,
onCreateMapping,
onDeleteMapping,
onNext,
onBack,
}) => {
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
// 컬럼 정보 로드
useEffect(() => {
const loadColumns = async () => {
if (!fromConnection || !toConnection || !fromTable || !toTable) {
return;
}
try {
setIsLoading(true);
const [fromCols, toCols] = await Promise.all([
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
getColumnsFromConnection(toConnection.id, toTable.tableName),
]);
setFromColumns(Array.isArray(fromCols) ? fromCols : []);
setToColumns(Array.isArray(toCols) ? toCols : []);
} catch (error) {
console.error("❌ 컬럼 정보 로드 실패:", error);
toast.error("필드 정보를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadColumns();
}, [fromConnection, toConnection, fromTable, toTable]);
// 그룹 확장/축소 토글
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),
);
// 탭 정보
const tabs = [
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
...(hasInsertActions
? [{ id: "mapping" as const, label: "컬럼 매핑", icon: "🔗", description: "INSERT 액션 필드 매핑" }]
: []),
];
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>
{/* 액션 그룹 목록 */}
<div className="space-y-4">
{actionGroups.map((group, groupIndex) => (
<div key={group.id} 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>
<div className="flex items-center gap-2">
{/* 그룹 논리 연산자 선택 */}
<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 })}
onClick={(e) => e.stopPropagation()}
/>
{/* 그룹 삭제 */}
{actionGroups.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDeleteActionGroup(group.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CollapsibleTrigger>
{/* 그룹 내용 */}
<CollapsibleContent>
<div className="bg-muted/20 border-t p-4">
{/* 액션 추가 버튼 */}
<div className="mb-4 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => onAddActionToGroup(group.id)}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 액션 목록 */}
<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>
</div>
{/* 액션 조건 설정 */}
<ActionConditionBuilder
actionType={action.actionType}
fromColumns={fromColumns}
toColumns={toColumns}
conditions={action.conditions}
fieldMappings={action.fieldMappings}
onConditionsChange={(conditions) =>
onUpdateActionInGroup(group.id, action.id, { conditions })
}
onFieldMappingsChange={(fieldMappings) =>
onUpdateActionInGroup(group.id, action.id, { fieldMappings })
}
/>
</div>
))}
</div>
{/* 그룹 로직 설명 */}
<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>
</div>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* 그룹 간 연결선 (마지막 그룹이 아닌 경우) */}
{groupIndex < actionGroups.length - 1 && (
<div className="flex justify-center py-2">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<div className="bg-border h-px w-8"></div>
<span> </span>
<div className="bg-border h-px w-8"></div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{activeTab === "mapping" && hasInsertActions && (
<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">
{fieldMappings.length}
</Badge>
</div>
<div className="text-muted-foreground text-sm">INSERT </div>
</div>
{/* 컬럼 매핑 캔버스 */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground"> ...</div>
</div>
) : fromColumns.length > 0 && toColumns.length > 0 ? (
<div className="rounded-lg border bg-white p-4">
<FieldMappingCanvas
fromFields={fromColumns}
toFields={toColumns}
mappings={fieldMappings}
onCreateMapping={onCreateMapping}
onDeleteMapping={onDeleteMapping}
/>
</div>
) : (
<div className="flex h-64 flex-col items-center justify-center space-y-3 rounded-lg border border-dashed">
<AlertTriangle className="text-muted-foreground h-8 w-8" />
<div className="text-muted-foreground"> .</div>
<div className="text-muted-foreground text-xs">
FROM : {fromColumns.length}, TO : {toColumns.length}
</div>
</div>
)}
{/* 매핑되지 않은 필드 처리 옵션 */}
<div className="rounded-md border bg-yellow-50 p-4">
<h4 className="mb-3 flex items-center gap-2 font-medium text-yellow-800">
<AlertTriangle className="h-4 w-4" />
</h4>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<input type="radio" id="empty" name="unmapped-strategy" defaultChecked className="h-4 w-4" />
<label htmlFor="empty" className="text-yellow-700">
(NULL )
</label>
</div>
<div className="flex items-center gap-2">
<input type="radio" id="default" name="unmapped-strategy" className="h-4 w-4" />
<label htmlFor="default" className="text-yellow-700">
( DEFAULT )
</label>
</div>
<div className="flex items-center gap-2">
<input type="radio" id="skip" name="unmapped-strategy" className="h-4 w-4" />
<label htmlFor="skip" className="text-yellow-700">
(INSERT )
</label>
</div>
</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;