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

701 lines
34 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
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;
// 컬럼 정보 (중앙에서 관리) 🔧 추가
fromColumns?: ColumnInfo[];
toColumns?: ColumnInfo[];
// 제어 조건 관련
controlConditions: any[];
onUpdateControlCondition: (index: number, condition: any) => void;
onDeleteControlCondition: (index: number) => void;
onAddControlCondition: () => void;
// 액션 그룹 관련
actionGroups: ActionGroup[];
groupsLogicalOperator?: "AND" | "OR";
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;
onSetGroupsLogicalOperator?: (operator: "AND" | "OR") => void;
// 필드 매핑 관련
fieldMappings: FieldMapping[];
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
onDeleteMapping: (mappingId: string) => void;
// 네비게이션
onNext: () => void;
onBack: () => void;
// 컬럼 로드 액션
onLoadColumns?: () => Promise<void>;
}
/**
* 🎯 4단계: 통합된 멀티 액션 설정
* - 제어 조건 설정
* - 여러 액션 그룹 관리
* - AND/OR 논리 연산자
* - 액션별 조건 설정
* - INSERT 액션 시 컬럼 매핑
*/
const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
fromTable,
toTable,
fromConnection,
toConnection,
fromColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
toColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
controlConditions,
onUpdateControlCondition,
onDeleteControlCondition,
onAddControlCondition,
actionGroups,
groupsLogicalOperator = "AND",
onUpdateActionGroup,
onDeleteActionGroup,
onAddActionGroup,
onAddActionToGroup,
onUpdateActionInGroup,
onDeleteActionFromGroup,
onSetGroupsLogicalOperator,
fieldMappings,
onCreateMapping,
onDeleteMapping,
onNext,
onBack,
onLoadColumns,
}) => {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
// 컬럼 로딩 상태 확인
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
// 컴포넌트 마운트 시 컬럼 로드
useEffect(() => {
if (!isColumnsLoaded && fromConnection && toConnection && fromTable && toTable && onLoadColumns) {
console.log("🔄 MultiActionConfigStep: 컬럼 로드 시작");
onLoadColumns();
}
}, [isColumnsLoaded, fromConnection?.id, toConnection?.id, fromTable?.tableName, toTable?.tableName]);
// 그룹 확장/축소 토글
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-primary/20 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: "액션 그룹 및 실행 조건" },
];
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>
{/* 그룹 간 논리 연산자 선택 */}
{actionGroups.length > 1 && (
<div className="rounded-md border bg-accent 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>
)}
{/* 액션 그룹 목록 */}
<div className="space-y-4">
{actionGroups.map((group, groupIndex) => (
<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>
<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>
{/* 액션 조건 설정 */}
{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,
)
}
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],
});
}}
/>
) : (
<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>
)}
</div>
))}
</div>
{/* 그룹 로직 설명 */}
<div className="mt-4 rounded-md bg-accent p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 text-primary" />
<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>
</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;