제어관리 외부커넥션 설정기능
This commit is contained in:
@@ -240,7 +240,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
관계도 설계
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, Trash2 } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings as DataSaveSettingsType } from "@/types/connectionTypes";
|
||||
import { ActionConditionsSection } from "./ActionConditionsSection";
|
||||
import { ActionFieldMappings } from "./ActionFieldMappings";
|
||||
import { ActionSplitConfig } from "./ActionSplitConfig";
|
||||
|
||||
// 🎨 새로운 UI 컴포넌트 import
|
||||
import DataConnectionDesigner from "./redesigned/DataConnectionDesigner";
|
||||
|
||||
interface DataSaveSettingsProps {
|
||||
settings: DataSaveSettingsType;
|
||||
@@ -23,6 +18,11 @@ interface DataSaveSettingsProps {
|
||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎨 데이터 저장 설정 컴포넌트
|
||||
* - 항상 새로운 UI (DataConnectionDesigner) 사용
|
||||
* - 기존 UI는 더 이상 사용하지 않음
|
||||
*/
|
||||
export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
@@ -33,195 +33,13 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||
toTableName,
|
||||
tableColumnsCache,
|
||||
}) => {
|
||||
const addAction = () => {
|
||||
const newAction = {
|
||||
id: `action_${settings.actions.length + 1}`,
|
||||
name: `액션 ${settings.actions.length + 1}`,
|
||||
actionType: "insert" as const,
|
||||
// 첫 번째 액션이 아니면 기본적으로 AND 연산자 추가
|
||||
...(settings.actions.length > 0 && { logicalOperator: "AND" as const }),
|
||||
fieldMappings: [],
|
||||
conditions: [],
|
||||
splitConfig: {
|
||||
sourceField: "",
|
||||
delimiter: "",
|
||||
targetField: "",
|
||||
},
|
||||
};
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
actions: [...settings.actions, newAction],
|
||||
});
|
||||
};
|
||||
|
||||
const updateAction = (actionIndex: number, field: string, value: any) => {
|
||||
const newActions = [...settings.actions];
|
||||
(newActions[actionIndex] as any)[field] = value;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const removeAction = (actionIndex: number) => {
|
||||
const newActions = settings.actions.filter((_, i) => i !== actionIndex);
|
||||
|
||||
// 첫 번째 액션을 삭제했다면, 새로운 첫 번째 액션의 logicalOperator 제거
|
||||
if (actionIndex === 0 && newActions.length > 0) {
|
||||
delete newActions[0].logicalOperator;
|
||||
}
|
||||
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
// 🎨 항상 새로운 UI 사용
|
||||
return (
|
||||
<div className="rounded-lg border border-l-4 border-l-green-500 bg-green-50/30 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Save className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">데이터 저장 설정</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* 액션 목록 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">저장 액션</Label>
|
||||
<Button size="sm" variant="outline" onClick={addAction} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
액션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{settings.actions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-gray-500">
|
||||
저장 액션을 추가하여 데이터를 어떻게 저장할지 설정하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{settings.actions.map((action, actionIndex) => (
|
||||
<div key={action.id}>
|
||||
{/* 첫 번째 액션이 아닌 경우 논리 연산자 표시 */}
|
||||
{actionIndex > 0 && (
|
||||
<div className="mb-2 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-gray-100 px-3 py-1">
|
||||
<span className="text-xs text-gray-600">이전 액션과의 관계:</span>
|
||||
<Select
|
||||
value={action.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => updateAction(actionIndex, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded border bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Input
|
||||
value={action.name}
|
||||
onChange={(e) => updateAction(actionIndex, "name", e.target.value)}
|
||||
className="h-7 flex-1 text-xs font-medium"
|
||||
placeholder="액션 이름"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeAction(actionIndex)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{/* 액션 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">액션 타입</Label>
|
||||
<Select
|
||||
value={action.actionType}
|
||||
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
||||
updateAction(actionIndex, "actionType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<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>
|
||||
|
||||
{/* 액션별 개별 실행 조건 */}
|
||||
<ActionConditionsSection
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
/>
|
||||
|
||||
{/* 데이터 분할 설정 - DELETE 액션은 제외 */}
|
||||
{action.actionType !== "delete" && (
|
||||
<ActionSplitConfig
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 - DELETE 액션은 제외 */}
|
||||
{action.actionType !== "delete" && (
|
||||
<ActionFieldMappings
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
availableTables={availableTables}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
enableMultiConnection={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DELETE 액션일 때 다중 커넥션 지원 */}
|
||||
{action.actionType === "delete" && (
|
||||
<ActionFieldMappings
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
availableTables={availableTables}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
enableMultiConnection={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataConnectionDesigner
|
||||
onClose={undefined} // 닫기 버튼 제거 (항상 새 UI 사용)
|
||||
initialData={{
|
||||
connectionType: "data_save",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { X, ArrowLeft } from "lucide-react";
|
||||
|
||||
// API import
|
||||
import { saveDataflowRelationship } from "@/lib/api/dataflowSave";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
DataConnectionState,
|
||||
DataConnectionActions,
|
||||
DataConnectionDesignerProps,
|
||||
FieldMapping,
|
||||
ValidationResult,
|
||||
TestResult,
|
||||
MappingStats,
|
||||
ActionGroup,
|
||||
SingleAction,
|
||||
} from "./types/redesigned";
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import LeftPanel from "./LeftPanel/LeftPanel";
|
||||
import RightPanel from "./RightPanel/RightPanel";
|
||||
import SaveRelationshipDialog from "./SaveRelationshipDialog";
|
||||
|
||||
/**
|
||||
* 🎨 데이터 연결 설정 메인 디자이너
|
||||
* - 좌우 분할 레이아웃 (30% + 70%)
|
||||
* - 상태 관리 및 액션 처리
|
||||
* - 기존 모달 기능을 메인 화면으로 통합
|
||||
*/
|
||||
const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||
onClose,
|
||||
initialData,
|
||||
showBackButton = false,
|
||||
}) => {
|
||||
// 🔄 상태 관리
|
||||
const [state, setState] = useState<DataConnectionState>(() => ({
|
||||
connectionType: "data_save",
|
||||
currentStep: 1,
|
||||
fieldMappings: [],
|
||||
mappingStats: {
|
||||
totalMappings: 0,
|
||||
validMappings: 0,
|
||||
invalidMappings: 0,
|
||||
missingRequiredFields: 0,
|
||||
estimatedRows: 0,
|
||||
actionType: "INSERT",
|
||||
},
|
||||
// 제어 실행 조건 초기값
|
||||
controlConditions: [],
|
||||
|
||||
// 액션 그룹 초기값 (멀티 액션)
|
||||
actionGroups: [
|
||||
{
|
||||
id: "group_1",
|
||||
name: "기본 액션 그룹",
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
|
||||
// 기존 호환성 필드들 (deprecated)
|
||||
actionType: "insert",
|
||||
actionConditions: [],
|
||||
actionFieldMappings: [],
|
||||
isLoading: false,
|
||||
validationErrors: [],
|
||||
...initialData,
|
||||
}));
|
||||
|
||||
// 💾 저장 다이얼로그 상태
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 🔄 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (initialData && Object.keys(initialData).length > 1) {
|
||||
console.log("🔄 초기 데이터 로드:", initialData);
|
||||
|
||||
// 로드된 데이터로 state 업데이트
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: initialData.connectionType || prev.connectionType,
|
||||
fromConnection: initialData.fromConnection || prev.fromConnection,
|
||||
toConnection: initialData.toConnection || prev.toConnection,
|
||||
fromTable: initialData.fromTable || prev.fromTable,
|
||||
toTable: initialData.toTable || prev.toTable,
|
||||
actionType: initialData.actionType || prev.actionType,
|
||||
controlConditions: initialData.controlConditions || prev.controlConditions,
|
||||
actionConditions: initialData.actionConditions || prev.actionConditions,
|
||||
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
||||
currentStep: initialData.fromConnection && initialData.toConnection ? 2 : 1, // 연결 정보가 있으면 2단계부터 시작
|
||||
}));
|
||||
|
||||
console.log("✅ 초기 데이터 로드 완료");
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 🎯 액션 핸들러들
|
||||
const actions: DataConnectionActions = {
|
||||
// 연결 타입 설정
|
||||
setConnectionType: useCallback((type: "data_save" | "external_call") => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: type,
|
||||
// 타입 변경 시 상태 초기화
|
||||
currentStep: 1,
|
||||
fromConnection: undefined,
|
||||
toConnection: undefined,
|
||||
fromTable: undefined,
|
||||
toTable: undefined,
|
||||
fieldMappings: [],
|
||||
validationErrors: [],
|
||||
}));
|
||||
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
// 단계 이동
|
||||
goToStep: useCallback((step: 1 | 2 | 3 | 4) => {
|
||||
setState((prev) => ({ ...prev, currentStep: step }));
|
||||
}, []),
|
||||
|
||||
// 연결 선택
|
||||
selectConnection: useCallback((type: "from" | "to", connection: Connection) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromConnection" : "toConnection"]: connection,
|
||||
// 연결 변경 시 테이블과 매핑 초기화
|
||||
[type === "from" ? "fromTable" : "toTable"]: undefined,
|
||||
fieldMappings: [],
|
||||
}));
|
||||
toast.success(`${type === "from" ? "소스" : "대상"} 연결이 선택되었습니다: ${connection.name}`);
|
||||
}, []),
|
||||
|
||||
// 테이블 선택
|
||||
selectTable: useCallback((type: "from" | "to", table: TableInfo) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromTable" : "toTable"]: table,
|
||||
// 테이블 변경 시 매핑 초기화
|
||||
fieldMappings: [],
|
||||
}));
|
||||
toast.success(
|
||||
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
||||
);
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 생성
|
||||
createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||
const newMapping: FieldMapping = {
|
||||
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
||||
fromField,
|
||||
toField,
|
||||
isValid: true, // 기본적으로 유효하다고 가정, 나중에 검증
|
||||
validationMessage: undefined,
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: [...prev.fieldMappings, newMapping],
|
||||
}));
|
||||
|
||||
toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`);
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 업데이트
|
||||
updateMapping: useCallback((mappingId: string, updates: Partial<FieldMapping>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.map((mapping) =>
|
||||
mapping.id === mappingId ? { ...mapping, ...updates } : mapping,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 삭제
|
||||
deleteMapping: useCallback((mappingId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId),
|
||||
}));
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 검증
|
||||
validateMappings: useCallback(async (): Promise<ValidationResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 검증 로직 구현
|
||||
const result: ValidationResult = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
validationErrors: result.errors,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
|
||||
// 제어 조건 관리 (전체 실행 조건)
|
||||
addControlCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: [
|
||||
...prev.controlConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateControlCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteControlCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("제어 조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 액션 설정 관리
|
||||
setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionType: type,
|
||||
// INSERT가 아닌 경우 조건 초기화
|
||||
actionConditions: type === "insert" ? [] : prev.actionConditions,
|
||||
}));
|
||||
toast.success(`액션 타입이 ${type.toUpperCase()}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
addActionCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: [
|
||||
...prev.actionConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateActionCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 🔧 액션 조건 배열 전체 업데이트 (ActionConditionBuilder용)
|
||||
setActionConditions: useCallback((conditions: any[]) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: conditions,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 🎯 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: useCallback(() => {
|
||||
const newGroupId = `group_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: [
|
||||
...prev.actionGroups,
|
||||
{
|
||||
id: newGroupId,
|
||||
name: `액션 그룹 ${prev.actionGroups.length + 1}`,
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: `action_${Date.now()}`,
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
toast.success("새 액션 그룹이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionGroup: useCallback((groupId: string, updates: Partial<ActionGroup>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) => (group.id === groupId ? { ...group, ...updates } : group)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionGroup: useCallback((groupId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.filter((group) => group.id !== groupId),
|
||||
}));
|
||||
toast.success("액션 그룹이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
addActionToGroup: useCallback((groupId: string) => {
|
||||
const newActionId = `action_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: [
|
||||
...group.actions,
|
||||
{
|
||||
id: newActionId,
|
||||
name: `액션 ${group.actions.length + 1}`,
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("새 액션이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionInGroup: useCallback((groupId: string, actionId: string, updates: Partial<SingleAction>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.map((action) => (action.id === actionId ? { ...action, ...updates } : action)),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionFromGroup: useCallback((groupId: string, actionId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.filter((action) => action.id !== actionId),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("액션이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 저장 (다이얼로그 표시)
|
||||
saveMappings: useCallback(async () => {
|
||||
setShowSaveDialog(true);
|
||||
}, []),
|
||||
|
||||
// 테스트 실행
|
||||
testExecution: useCallback(async (): Promise<TestResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 테스트 로직 구현
|
||||
const result: TestResult = {
|
||||
success: true,
|
||||
message: "테스트가 성공적으로 완료되었습니다.",
|
||||
affectedRows: 10,
|
||||
executionTime: 250,
|
||||
};
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(result.message);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error("테스트 실행 중 오류가 발생했습니다.");
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
};
|
||||
|
||||
// 💾 실제 저장 함수
|
||||
const handleSaveWithName = useCallback(
|
||||
async (relationshipName: string, description?: string) => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// 실제 저장 로직 구현
|
||||
const saveData = {
|
||||
relationshipName,
|
||||
description,
|
||||
connectionType: state.connectionType,
|
||||
fromConnection: state.fromConnection,
|
||||
toConnection: state.toConnection,
|
||||
fromTable: state.fromTable,
|
||||
toTable: state.toTable,
|
||||
actionType: state.actionType,
|
||||
controlConditions: state.controlConditions,
|
||||
actionConditions: state.actionConditions,
|
||||
fieldMappings: state.fieldMappings,
|
||||
};
|
||||
|
||||
console.log("💾 저장 시작:", saveData);
|
||||
|
||||
// 백엔드 API 호출
|
||||
const result = await saveDataflowRelationship(saveData);
|
||||
|
||||
console.log("✅ 저장 완료:", result);
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(`"${relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
||||
|
||||
// 저장 후 상위 컴포넌트에 알림 (필요한 경우)
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
|
||||
const errorMessage = error.message || "저장 중 오류가 발생했습니다.";
|
||||
toast.error(errorMessage);
|
||||
console.error("❌ 저장 오류:", error);
|
||||
}
|
||||
},
|
||||
[state, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
{/* 상단 네비게이션 */}
|
||||
{showBackButton && (
|
||||
<div className="flex-shrink-0 border-b bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={onClose} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">🔗 데이터 연결 설정</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{state.connectionType === "data_save" ? "데이터 저장" : "외부 호출"} 연결 설정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
|
||||
<div className="flex h-[calc(100vh-280px)] min-h-[600px] overflow-hidden">
|
||||
{/* 좌측 패널 (30%) */}
|
||||
<div className="flex w-[30%] flex-col border-r bg-white">
|
||||
<LeftPanel state={state} actions={actions} />
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 (70%) */}
|
||||
<div className="flex w-[70%] flex-col bg-gray-50">
|
||||
<RightPanel state={state} actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 💾 저장 다이얼로그 */}
|
||||
<SaveRelationshipDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={handleSaveWithName}
|
||||
actionType={state.actionType}
|
||||
fromTable={state.fromTable?.tableName}
|
||||
toTable={state.toTable?.tableName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataConnectionDesigner;
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Save, Eye, TestTube, Copy, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
|
||||
interface ActionButtonsProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 액션 버튼들
|
||||
* - 저장, 미리보기, 테스트 실행
|
||||
* - 설정 복사, 초기화
|
||||
*/
|
||||
const ActionButtons: React.FC<ActionButtonsProps> = ({ state, actions }) => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await actions.saveMappings();
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
// TODO: 미리보기 모달 열기
|
||||
toast.info("미리보기 기능은 곧 구현될 예정입니다.");
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
await actions.testExecution();
|
||||
} catch (error) {
|
||||
console.error("테스트 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySettings = () => {
|
||||
// TODO: 설정 복사 기능
|
||||
toast.info("설정 복사 기능은 곧 구현될 예정입니다.");
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirm("모든 설정을 초기화하시겠습니까?")) {
|
||||
// TODO: 상태 초기화
|
||||
toast.success("설정이 초기화되었습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = state.fieldMappings.length > 0 && !state.isLoading;
|
||||
const canTest = state.fieldMappings.length > 0 && !state.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 주요 액션 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button onClick={handleSave} disabled={!canSave} className="h-8 text-xs">
|
||||
{state.isLoading ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <Save className="mr-1 h-3 w-3" />}
|
||||
저장
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handlePreview} className="h-8 text-xs">
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
미리보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button variant="secondary" onClick={handleTest} disabled={!canTest} className="h-8 w-full text-xs">
|
||||
{state.isLoading ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <TestTube className="mr-1 h-3 w-3" />}
|
||||
테스트 실행
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 보조 액션 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleCopySettings} className="h-7 text-xs">
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
설정 복사
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="text-destructive hover:text-destructive h-7 text-xs"
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 상태 정보 */}
|
||||
{state.fieldMappings.length > 0 && (
|
||||
<div className="text-muted-foreground border-t pt-2 text-center text-xs">
|
||||
{state.fieldMappings.length}개 매핑 설정됨
|
||||
{state.validationErrors.length > 0 && (
|
||||
<span className="ml-1 text-orange-600">({state.validationErrors.length}개 오류)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButtons;
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings, CheckCircle, AlertCircle } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState } from "../types/redesigned";
|
||||
|
||||
interface ActionSummaryPanelProps {
|
||||
state: DataConnectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 액션 설정 요약 패널
|
||||
* - 액션 타입 표시
|
||||
* - 실행 조건 요약
|
||||
* - 설정 완료 상태
|
||||
*/
|
||||
const ActionSummaryPanel: React.FC<ActionSummaryPanelProps> = ({ state }) => {
|
||||
const { actionType, actionConditions } = state;
|
||||
|
||||
const isConfigured = actionType && (actionType === "insert" || actionConditions.length > 0);
|
||||
|
||||
const actionTypeLabels = {
|
||||
insert: "INSERT",
|
||||
update: "UPDATE",
|
||||
delete: "DELETE",
|
||||
upsert: "UPSERT",
|
||||
};
|
||||
|
||||
const actionTypeDescriptions = {
|
||||
insert: "새 데이터 삽입",
|
||||
update: "기존 데이터 수정",
|
||||
delete: "데이터 삭제",
|
||||
upsert: "있으면 수정, 없으면 삽입",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
액션 설정
|
||||
{isConfigured ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 px-4 pt-0 pb-4">
|
||||
{/* 액션 타입 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">액션 타입</span>
|
||||
{actionType ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{actionTypeLabels[actionType]}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">미설정</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionType && <p className="text-muted-foreground text-xs">{actionTypeDescriptions[actionType]}</p>}
|
||||
</div>
|
||||
|
||||
{/* 실행 조건 */}
|
||||
{actionType && actionType !== "insert" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">실행 조건</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{actionConditions.length === 0 && (
|
||||
<p className="text-xs text-orange-600">⚠️ {actionType.toUpperCase()} 액션은 실행 조건이 필요합니다</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INSERT 액션 안내 */}
|
||||
{actionType === "insert" && (
|
||||
<div className="rounded-md border border-green-200 bg-green-50 p-2">
|
||||
<p className="text-xs text-green-700">✅ INSERT 액션은 별도 조건 없이 모든 매핑된 데이터를 삽입합니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 상태 */}
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConfigured ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
<span className="text-xs font-medium text-green-600">설정 완료</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3 w-3 text-orange-500" />
|
||||
<span className="text-xs font-medium text-orange-600">설정 필요</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionSummaryPanel;
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown, Settings } from "lucide-react";
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
connectionType: "data_save" | "external_call";
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚙️ 고급 설정 패널
|
||||
* - 트랜잭션 설정
|
||||
* - 배치 처리 설정
|
||||
* - 로깅 설정
|
||||
*/
|
||||
const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [settings, setSettings] = useState({
|
||||
batchSize: 1000,
|
||||
timeout: 30,
|
||||
retryCount: 3,
|
||||
logLevel: "INFO",
|
||||
});
|
||||
|
||||
const handleSettingChange = (key: string, value: string | number) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto w-full justify-between p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="font-medium">고급 설정</span>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-3 px-4 pt-0 pb-3">
|
||||
{connectionType === "data_save" && (
|
||||
<>
|
||||
{/* 트랜잭션 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">🔄 트랜잭션 설정</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs text-gray-500">
|
||||
배치
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={settings.batchSize}
|
||||
onChange={(e) => handleSettingChange("batchSize", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs text-gray-500">
|
||||
타임아웃
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => handleSettingChange("timeout", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="retryCount" className="text-xs text-gray-500">
|
||||
재시도
|
||||
</Label>
|
||||
<Input
|
||||
id="retryCount"
|
||||
type="number"
|
||||
value={settings.retryCount}
|
||||
onChange={(e) => handleSettingChange("retryCount", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{connectionType === "external_call" && (
|
||||
<>
|
||||
{/* API 호출 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">🌐 API 호출 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs text-gray-500">
|
||||
타임아웃 (초)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => handleSettingChange("timeout", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="retryCount" className="text-xs text-gray-500">
|
||||
재시도 횟수
|
||||
</Label>
|
||||
<Input
|
||||
id="retryCount"
|
||||
type="number"
|
||||
value={settings.retryCount}
|
||||
onChange={(e) => handleSettingChange("retryCount", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 로깅 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">📝 로깅 설정</h4>
|
||||
<div>
|
||||
<Select value={settings.logLevel} onValueChange={(value) => handleSettingChange("logLevel", value)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="로그 레벨 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="DEBUG">DEBUG</SelectItem>
|
||||
<SelectItem value="INFO">INFO</SelectItem>
|
||||
<SelectItem value="WARN">WARN</SelectItem>
|
||||
<SelectItem value="ERROR">ERROR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 요약 - 더 컴팩트 */}
|
||||
<div className="border-t pt-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
배치: {settings.batchSize.toLocaleString()} | 타임아웃: {settings.timeout}s | 재시도:{" "}
|
||||
{settings.retryCount} | 로그: {settings.logLevel}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedSettings;
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ConnectionType, ConnectionTypeSelectorProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 🔘 연결 타입 선택 컴포넌트
|
||||
* - 데이터 저장 (INSERT/UPDATE/DELETE)
|
||||
* - 외부 호출 (API/Webhook)
|
||||
*/
|
||||
const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selectedType, onTypeChange }) => {
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<RadioGroup
|
||||
value={selectedType}
|
||||
onValueChange={(value) => onTypeChange(value as "data_save" | "external_call")}
|
||||
className="space-y-3"
|
||||
>
|
||||
{connectionTypes.map((type) => (
|
||||
<div key={type.id} className="flex items-start space-x-3">
|
||||
<RadioGroupItem value={type.id} id={type.id} className="mt-1" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={type.id} className="flex cursor-pointer items-center gap-2 font-medium">
|
||||
{type.icon}
|
||||
{type.label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionTypeSelector;
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
// 타입 import
|
||||
import { LeftPanelProps } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import ConnectionTypeSelector from "./ConnectionTypeSelector";
|
||||
import MappingDetailList from "./MappingDetailList";
|
||||
import ActionSummaryPanel from "./ActionSummaryPanel";
|
||||
import AdvancedSettings from "./AdvancedSettings";
|
||||
import ActionButtons from "./ActionButtons";
|
||||
|
||||
/**
|
||||
* 📋 좌측 패널 (30% 너비)
|
||||
* - 연결 타입 선택
|
||||
* - 매핑 정보 모니터링
|
||||
* - 상세 설정 목록
|
||||
* - 액션 버튼들
|
||||
*/
|
||||
const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<ScrollArea className="flex-1 p-3 pb-0">
|
||||
<div className="space-y-3 pb-3">
|
||||
{/* 0단계: 연결 타입 선택 */}
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">0단계: 연결 타입</h3>
|
||||
<ConnectionTypeSelector selectedType={state.connectionType} onTypeChange={actions.setConnectionType} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 매핑 상세 목록 */}
|
||||
{state.fieldMappings.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">매핑 상세 목록</h3>
|
||||
<MappingDetailList
|
||||
mappings={state.fieldMappings}
|
||||
selectedMapping={state.selectedMapping}
|
||||
onSelectMapping={(mappingId) => {
|
||||
// TODO: 선택된 매핑 상태 업데이트
|
||||
}}
|
||||
onUpdateMapping={actions.updateMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 액션 설정 요약 */}
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">액션 설정</h3>
|
||||
<ActionSummaryPanel state={state} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 고급 설정 */}
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">고급 설정</h3>
|
||||
<AdvancedSettings connectionType={state.connectionType} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 하단 액션 버튼들 - 고정 위치 */}
|
||||
<div className="flex-shrink-0 border-t bg-white p-3 shadow-sm">
|
||||
<ActionButtons state={state} actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftPanel;
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { CheckCircle, AlertTriangle, Edit, Trash2 } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { MappingDetailListProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 📝 매핑 상세 목록
|
||||
* - 각 매핑별 상세 정보
|
||||
* - 타입 변환 정보
|
||||
* - 개별 수정/삭제 기능
|
||||
*/
|
||||
const MappingDetailList: React.FC<MappingDetailListProps> = ({
|
||||
mappings,
|
||||
selectedMapping,
|
||||
onSelectMapping,
|
||||
onUpdateMapping,
|
||||
onDeleteMapping,
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="space-y-3 p-4">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div
|
||||
key={mapping.id}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
selectedMapping === mapping.id ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => onSelectMapping(mapping.id)}
|
||||
>
|
||||
{/* 매핑 헤더 */}
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-sm font-medium">
|
||||
{index + 1}. {mapping.fromField.displayName || mapping.fromField.columnName} →{" "}
|
||||
{mapping.toField.displayName || mapping.toField.columnName}
|
||||
</h4>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{mapping.isValid ? (
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{mapping.fromField.webType} → {mapping.toField.webType}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-orange-600">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
타입 불일치
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: 매핑 편집 모달 열기
|
||||
}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteMapping(mapping.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변환 규칙 */}
|
||||
{mapping.transformRule && (
|
||||
<div className="text-muted-foreground text-xs">변환: {mapping.transformRule}</div>
|
||||
)}
|
||||
|
||||
{/* 검증 메시지 */}
|
||||
{mapping.validationMessage && (
|
||||
<div className="mt-1 text-xs text-orange-600">{mapping.validationMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mappings.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
<p>매핑된 필드가 없습니다.</p>
|
||||
<p className="mt-1 text-xs">우측에서 필드를 연결해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MappingDetailList;
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CheckCircle, AlertTriangle, XCircle, Info } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { MappingInfoPanelProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 📊 매핑 정보 패널
|
||||
* - 실시간 매핑 통계
|
||||
* - 검증 상태 표시
|
||||
* - 예상 처리량 정보
|
||||
*/
|
||||
const MappingInfoPanel: React.FC<MappingInfoPanelProps> = ({ stats, validationErrors }) => {
|
||||
const errorCount = validationErrors.filter((e) => e.type === "error").length;
|
||||
const warningCount = validationErrors.filter((e) => e.type === "warning").length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{/* 매핑 통계 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">총 매핑:</span>
|
||||
<Badge variant="outline">{stats.totalMappings}개</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">유효한 매핑:</span>
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{stats.validMappings}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{stats.invalidMappings > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">타입 불일치:</span>
|
||||
<Badge variant="outline" className="text-orange-600">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{stats.invalidMappings}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.missingRequiredFields > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">필수 필드 누락:</span>
|
||||
<Badge variant="outline" className="text-red-600">
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{stats.missingRequiredFields}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 정보 */}
|
||||
{stats.totalMappings > 0 && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">액션:</span>
|
||||
<Badge variant="secondary">{stats.actionType}</Badge>
|
||||
</div>
|
||||
|
||||
{stats.estimatedRows > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">예상 처리량:</span>
|
||||
<span className="font-medium">~{stats.estimatedRows.toLocaleString()} rows</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검증 오류 요약 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-muted-foreground">검증 결과:</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{errorCount > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
오류 {errorCount}개
|
||||
</Badge>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<Badge variant="outline" className="ml-1 text-xs text-orange-600">
|
||||
경고 {warningCount}개
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{stats.totalMappings === 0 && (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">
|
||||
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>아직 매핑된 필드가 없습니다.</p>
|
||||
<p className="mt-1 text-xs">우측에서 연결을 설정해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Database 아이콘 import 추가
|
||||
import { Database } from "lucide-react";
|
||||
|
||||
export default MappingInfoPanel;
|
||||
@@ -0,0 +1,546 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Trash2, Settings } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { getCodesForColumn, CodeItem } from "@/lib/api/codeManagement";
|
||||
|
||||
interface ActionCondition {
|
||||
id: string;
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "IS NULL" | "IS NOT NULL";
|
||||
value: string;
|
||||
valueType?: "static" | "field" | "calculated"; // 값 타입 (고정값, 필드값, 계산값)
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}
|
||||
|
||||
interface FieldValueMapping {
|
||||
id: string;
|
||||
targetField: string;
|
||||
valueType: "static" | "source_field" | "code" | "calculated";
|
||||
value: string;
|
||||
sourceField?: string;
|
||||
codeCategory?: string;
|
||||
}
|
||||
|
||||
interface ActionConditionBuilderProps {
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
fromColumns: ColumnInfo[];
|
||||
toColumns: ColumnInfo[];
|
||||
conditions: ActionCondition[];
|
||||
fieldMappings: FieldValueMapping[];
|
||||
onConditionsChange: (conditions: ActionCondition[]) => void;
|
||||
onFieldMappingsChange: (mappings: FieldValueMapping[]) => void;
|
||||
showFieldMappings?: boolean; // 필드 매핑 섹션 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 액션 조건 빌더
|
||||
* - 실행 조건 설정 (WHERE 절)
|
||||
* - 필드 값 매핑 설정 (SET 절)
|
||||
* - 코드 타입 필드 지원
|
||||
*/
|
||||
const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||
actionType,
|
||||
fromColumns,
|
||||
toColumns,
|
||||
conditions,
|
||||
fieldMappings,
|
||||
onConditionsChange,
|
||||
onFieldMappingsChange,
|
||||
showFieldMappings = true,
|
||||
}) => {
|
||||
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||
|
||||
const operators = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "큼 (>)" },
|
||||
{ value: "<", label: "작음 (<)" },
|
||||
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
||||
{ value: "IN", label: "목록 중 하나 (IN)" },
|
||||
{ value: "IS NULL", label: "빈 값 (IS NULL)" },
|
||||
{ value: "IS NOT NULL", label: "값 있음 (IS NOT NULL)" },
|
||||
];
|
||||
|
||||
// 코드 정보 로드
|
||||
useEffect(() => {
|
||||
const loadCodes = async () => {
|
||||
const codeFields = [...fromColumns, ...toColumns].filter(
|
||||
(col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
|
||||
);
|
||||
|
||||
for (const field of codeFields) {
|
||||
try {
|
||||
const codes = await getCodesForColumn(field.columnName, field.webType, field.codeCategory);
|
||||
|
||||
if (codes.length > 0) {
|
||||
setAvailableCodes((prev) => ({
|
||||
...prev,
|
||||
[field.columnName]: codes,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`코드 로드 실패: ${field.columnName}`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (fromColumns.length > 0 || toColumns.length > 0) {
|
||||
loadCodes();
|
||||
}
|
||||
}, [fromColumns, toColumns]);
|
||||
|
||||
// 조건 추가
|
||||
const addCondition = () => {
|
||||
const newCondition: ActionCondition = {
|
||||
id: Date.now().toString(),
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
...(conditions.length > 0 && { logicalOperator: "AND" }),
|
||||
};
|
||||
|
||||
onConditionsChange([...conditions, newCondition]);
|
||||
};
|
||||
|
||||
// 조건 업데이트
|
||||
const updateCondition = (index: number, updates: Partial<ActionCondition>) => {
|
||||
const updatedConditions = conditions.map((condition, i) =>
|
||||
i === index ? { ...condition, ...updates } : condition,
|
||||
);
|
||||
onConditionsChange(updatedConditions);
|
||||
};
|
||||
|
||||
// 조건 삭제
|
||||
const deleteCondition = (index: number) => {
|
||||
const updatedConditions = conditions.filter((_, i) => i !== index);
|
||||
onConditionsChange(updatedConditions);
|
||||
};
|
||||
|
||||
// 필드 매핑 추가
|
||||
const addFieldMapping = () => {
|
||||
const newMapping: FieldValueMapping = {
|
||||
id: Date.now().toString(),
|
||||
targetField: "",
|
||||
valueType: "static",
|
||||
value: "",
|
||||
};
|
||||
|
||||
onFieldMappingsChange([...fieldMappings, newMapping]);
|
||||
};
|
||||
|
||||
// 필드 매핑 업데이트
|
||||
const updateFieldMapping = (index: number, updates: Partial<FieldValueMapping>) => {
|
||||
const updatedMappings = fieldMappings.map((mapping, i) => (i === index ? { ...mapping, ...updates } : mapping));
|
||||
onFieldMappingsChange(updatedMappings);
|
||||
};
|
||||
|
||||
// 필드 매핑 삭제
|
||||
const deleteFieldMapping = (index: number) => {
|
||||
const updatedMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
onFieldMappingsChange(updatedMappings);
|
||||
};
|
||||
|
||||
// 필드의 값 입력 컴포넌트 렌더링
|
||||
const renderValueInput = (mapping: FieldValueMapping, index: number, targetColumn?: ColumnInfo) => {
|
||||
if (mapping.valueType === "code" && targetColumn?.webType === "code") {
|
||||
const codes = availableCodes[targetColumn.columnName] || [];
|
||||
|
||||
return (
|
||||
<Select value={mapping.value} onValueChange={(value) => updateFieldMapping(index, { value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codes.map((code) => (
|
||||
<SelectItem key={code.code} value={code.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{code.code}
|
||||
</Badge>
|
||||
<span>{code.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (mapping.valueType === "source_field") {
|
||||
return (
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => updateFieldMapping(index, { sourceField: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* FROM 테이블 필드들 */}
|
||||
{fromColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">FROM 테이블</div>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={`from_${column.columnName}`} value={`from.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">📤</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.webType || column.dataType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TO 테이블 필드들 */}
|
||||
{toColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">TO 테이블</div>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={`to_${column.columnName}`} value={`to.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600">📥</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.webType || column.dataType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder="값 입력"
|
||||
value={mapping.value}
|
||||
onChange={(e) => updateFieldMapping(index, { value: e.target.value })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 실행 조건 설정 */}
|
||||
{actionType !== "insert" && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>실행 조건 (WHERE)</span>
|
||||
<Button variant="outline" size="sm" onClick={addCondition}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{conditions.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{actionType.toUpperCase()} 액션의 실행 조건을 설정하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
conditions.map((condition, index) => (
|
||||
<div key={condition.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
{/* 논리 연산자 */}
|
||||
{index > 0 && (
|
||||
<Select
|
||||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value) => updateCondition(index, { logicalOperator: value as "AND" | "OR" })}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 필드 선택 */}
|
||||
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value })}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* FROM 테이블 컬럼들 */}
|
||||
{fromColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">FROM 테이블</div>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={`from_${column.columnName}`} value={`from.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">📤</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TO 테이블 컬럼들 */}
|
||||
{toColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">TO 테이블</div>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={`to_${column.columnName}`} value={`to.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600">📥</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => updateCondition(index, { operator: value as any })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{!["IS NULL", "IS NOT NULL"].includes(condition.operator) &&
|
||||
(() => {
|
||||
// FROM/TO 테이블 컬럼 구분
|
||||
let fieldColumn;
|
||||
let actualFieldName;
|
||||
if (condition.field?.startsWith("from.")) {
|
||||
actualFieldName = condition.field.replace("from.", "");
|
||||
fieldColumn = fromColumns.find((col) => col.columnName === actualFieldName);
|
||||
} else if (condition.field?.startsWith("to.")) {
|
||||
actualFieldName = condition.field.replace("to.", "");
|
||||
fieldColumn = toColumns.find((col) => col.columnName === actualFieldName);
|
||||
} else {
|
||||
// 기존 호환성을 위해 TO 테이블에서 먼저 찾기
|
||||
actualFieldName = condition.field;
|
||||
fieldColumn =
|
||||
toColumns.find((col) => col.columnName === condition.field) ||
|
||||
fromColumns.find((col) => col.columnName === condition.field);
|
||||
}
|
||||
|
||||
const fieldCodes = availableCodes[actualFieldName];
|
||||
|
||||
// 코드 타입 필드면 코드 선택
|
||||
if (fieldColumn?.webType === "code" && fieldCodes?.length > 0) {
|
||||
return (
|
||||
<Select value={condition.value} onValueChange={(value) => updateCondition(index, { value })}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldCodes.map((code) => (
|
||||
<SelectItem key={code.code} value={code.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{code.code}
|
||||
</Badge>
|
||||
<span>{code.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 값 타입 선택 (고정값, 다른 필드 값, 계산식 등)
|
||||
return (
|
||||
<div className="flex flex-1 gap-2">
|
||||
{/* 값 타입 선택 */}
|
||||
<Select
|
||||
value={condition.valueType || "static"}
|
||||
onValueChange={(valueType) => updateCondition(index, { valueType, value: "" })}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드값</SelectItem>
|
||||
<SelectItem value="calculated">계산값</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{condition.valueType === "field" ? (
|
||||
<Select
|
||||
value={condition.value}
|
||||
onValueChange={(value) => updateCondition(index, { value })}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* FROM 테이블 필드들 */}
|
||||
{fromColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">
|
||||
FROM 테이블
|
||||
</div>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={`from_${column.columnName}`} value={`from.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">📤</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TO 테이블 필드들 */}
|
||||
{toColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">TO 테이블</div>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={`to_${column.columnName}`} value={`to.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600">📥</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={condition.valueType === "calculated" ? "계산식 입력" : "값 입력"}
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteCondition(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 필드 값 매핑 설정 */}
|
||||
{showFieldMappings && actionType !== "delete" && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>필드 값 설정 (SET)</span>
|
||||
<Button variant="outline" size="sm" onClick={addFieldMapping}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{fieldMappings.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-sm">조건을 만족할 때 설정할 필드 값을 지정하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
fieldMappings.map((mapping, index) => {
|
||||
const targetColumn = toColumns.find((col) => col.columnName === mapping.targetField);
|
||||
|
||||
return (
|
||||
<div key={mapping.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
{/* 대상 필드 */}
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => updateFieldMapping(index, { targetField: value })}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="대상 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.webType || column.dataType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 타입 */}
|
||||
<Select
|
||||
value={mapping.valueType}
|
||||
onValueChange={(value) => updateFieldMapping(index, { valueType: value as any })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="source_field">소스필드</SelectItem>
|
||||
{targetColumn?.webType === "code" && <SelectItem value="code">코드선택</SelectItem>}
|
||||
<SelectItem value="calculated">계산식</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
<div className="flex-1">{renderValueInput(mapping, index, targetColumn)}</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteFieldMapping(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionConditionBuilder;
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
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
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import ActionConditionBuilder from "./ActionConfig/ActionConditionBuilder";
|
||||
|
||||
interface ActionConfigStepProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
onBack: () => void;
|
||||
onComplete: () => void;
|
||||
onSave?: () => void; // UPDATE/DELETE인 경우 저장 버튼
|
||||
showSaveButton?: boolean; // 저장 버튼 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 4단계: 액션 설정
|
||||
* - 액션 타입 선택 (INSERT/UPDATE/DELETE/UPSERT)
|
||||
* - 실행 조건 설정
|
||||
* - 액션별 상세 설정
|
||||
*/
|
||||
const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
|
||||
state,
|
||||
actions,
|
||||
onBack,
|
||||
onComplete,
|
||||
onSave,
|
||||
showSaveButton = false,
|
||||
}) => {
|
||||
const { actionType, actionConditions, fromTable, toTable, fromConnection, toConnection } = state;
|
||||
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
const [fieldMappings, setFieldMappings] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const actionTypes = [
|
||||
{ value: "insert", label: "INSERT", description: "새 데이터 삽입" },
|
||||
{ value: "update", label: "UPDATE", description: "기존 데이터 수정" },
|
||||
{ value: "delete", label: "DELETE", description: "데이터 삭제" },
|
||||
{ value: "upsert", label: "UPSERT", description: "있으면 수정, 없으면 삽입" },
|
||||
];
|
||||
|
||||
// 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!fromConnection || !toConnection || !fromTable || !toTable) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||
]);
|
||||
|
||||
setFromColumns(fromCols);
|
||||
setToColumns(toCols);
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||
|
||||
const canComplete =
|
||||
actionType &&
|
||||
(actionType === "insert" || (actionConditions.length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
4단계: 액션 설정
|
||||
</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>
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 액션 요약 */}
|
||||
{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" && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>필드 매핑:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<div className="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="flex gap-2">
|
||||
{showSaveButton && onSave && (
|
||||
<Button onClick={onSave} disabled={!canComplete} className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!showSaveButton && (
|
||||
<Button onClick={onComplete} disabled={!canComplete} className="flex items-center gap-2">
|
||||
다음: 컬럼 매핑
|
||||
<ArrowLeft className="h-4 w-4 rotate-180" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canComplete && (
|
||||
<p className="text-muted-foreground mt-2 text-center text-sm">
|
||||
{!actionType ? "액션 타입을 선택해주세요" : "실행 조건을 추가해주세요"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionConfigStep;
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { 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 { ArrowRight, Database, Globe, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection } from "@/lib/types/multiConnection";
|
||||
|
||||
interface ConnectionStepProps {
|
||||
connectionType: "data_save" | "external_call";
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔗 1단계: 연결 선택
|
||||
* - FROM/TO 데이터베이스 연결 선택
|
||||
* - 연결 상태 표시
|
||||
* - 지연시간 정보
|
||||
*/
|
||||
const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
||||
({ connectionType, fromConnection, toConnection, onSelectConnection, onNext }) => {
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// API 응답을 Connection 타입으로 변환
|
||||
const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({
|
||||
id: connectionInfo.id,
|
||||
name: connectionInfo.connection_name,
|
||||
type: connectionInfo.db_type,
|
||||
host: connectionInfo.host,
|
||||
port: connectionInfo.port,
|
||||
database: connectionInfo.database_name,
|
||||
username: connectionInfo.username,
|
||||
isActive: connectionInfo.is_active === "Y",
|
||||
companyCode: connectionInfo.company_code,
|
||||
createdDate: connectionInfo.created_date,
|
||||
updatedDate: connectionInfo.updated_date,
|
||||
});
|
||||
|
||||
// 연결 목록 로드
|
||||
useEffect(() => {
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getActiveConnections();
|
||||
|
||||
// 메인 DB 연결 추가
|
||||
const mainConnection: Connection = {
|
||||
id: 0,
|
||||
name: "메인 데이터베이스",
|
||||
type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "main",
|
||||
username: "main_user",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// API 응답을 Connection 타입으로 변환
|
||||
const convertedConnections = data.map(convertToConnection);
|
||||
|
||||
// 중복 방지: 기존에 메인 연결이 없는 경우에만 추가
|
||||
const hasMainConnection = convertedConnections.some((conn) => conn.id === 0);
|
||||
const preliminaryConnections = hasMainConnection
|
||||
? convertedConnections
|
||||
: [mainConnection, ...convertedConnections];
|
||||
|
||||
// ID 중복 제거 (Set 사용)
|
||||
const uniqueConnections = preliminaryConnections.filter(
|
||||
(conn, index, arr) => arr.findIndex((c) => c.id === conn.id) === index,
|
||||
);
|
||||
|
||||
console.log("🔗 연결 목록 로드 완료:", uniqueConnections);
|
||||
setConnections(uniqueConnections);
|
||||
} catch (error) {
|
||||
console.error("❌ 연결 목록 로드 실패:", error);
|
||||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
||||
|
||||
// 에러 시에도 메인 연결은 제공
|
||||
const mainConnection: Connection = {
|
||||
id: 0,
|
||||
name: "메인 데이터베이스",
|
||||
type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "main",
|
||||
username: "main_user",
|
||||
isActive: true,
|
||||
};
|
||||
setConnections([mainConnection]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
const handleConnectionSelect = (type: "from" | "to", connectionId: string) => {
|
||||
const connection = connections.find((c) => c.id.toString() === connectionId);
|
||||
if (connection) {
|
||||
onSelectConnection(type, connection);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = fromConnection && toConnection;
|
||||
|
||||
const getConnectionIcon = (connection: Connection) => {
|
||||
return connection.id === 0 ? <Database className="h-4 w-4" /> : <Globe className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getConnectionBadge = (connection: Connection) => {
|
||||
if (connection.id === 0) {
|
||||
return (
|
||||
<Badge variant="default" className="text-xs">
|
||||
메인 DB
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{connection.type?.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
1단계: 연결 선택
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{connectionType === "data_save"
|
||||
? "데이터를 저장할 소스와 대상 데이터베이스를 선택하세요."
|
||||
: "외부 호출을 위한 소스와 대상 연결을 선택하세요."}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||
<span>연결 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* FROM 연결 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">FROM 연결 (소스)</h3>
|
||||
{fromConnection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
🟢 연결됨
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">지연시간: ~23ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={fromConnection?.id.toString() || ""}
|
||||
onValueChange={(value) => handleConnectionSelect("from", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
||||
) : (
|
||||
connections.map((connection, index) => (
|
||||
<SelectItem key={`from_${connection.id}_${index}`} value={connection.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getConnectionIcon(connection)}
|
||||
<span>{connection.name}</span>
|
||||
{getConnectionBadge(connection)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{fromConnection && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{getConnectionIcon(fromConnection)}
|
||||
<span className="font-medium">{fromConnection.name}</span>
|
||||
{getConnectionBadge(fromConnection)}
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>
|
||||
호스트: {fromConnection.host}:{fromConnection.port}
|
||||
</p>
|
||||
<p>데이터베이스: {fromConnection.database}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TO 연결 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">TO 연결 (대상)</h3>
|
||||
{toConnection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
🟢 연결됨
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">지연시간: ~45ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={toConnection?.id.toString() || ""}
|
||||
onValueChange={(value) => handleConnectionSelect("to", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
||||
) : (
|
||||
connections.map((connection, index) => (
|
||||
<SelectItem key={`to_${connection.id}_${index}`} value={connection.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getConnectionIcon(connection)}
|
||||
<span>{connection.name}</span>
|
||||
{getConnectionBadge(connection)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{toConnection && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{getConnectionIcon(toConnection)}
|
||||
<span className="font-medium">{toConnection.name}</span>
|
||||
{getConnectionBadge(toConnection)}
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>
|
||||
호스트: {toConnection.host}:{toConnection.port}
|
||||
</p>
|
||||
<p>데이터베이스: {toConnection.database}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연결 매핑 표시 */}
|
||||
{fromConnection && toConnection && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{fromConnection.name}</div>
|
||||
<div className="text-muted-foreground text-xs">소스</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-primary h-5 w-5" />
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{toConnection.name}</div>
|
||||
<div className="text-muted-foreground text-xs">대상</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
💡 연결 매핑 설정 완료
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다음 단계 버튼 */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 테이블 선택
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ConnectionStep.displayName = "ConnectionStep";
|
||||
|
||||
export default ConnectionStep;
|
||||
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, CheckCircle, AlertCircle, Settings, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
import { getCodesForColumn, CodeItem } from "@/lib/api/codeManagement";
|
||||
|
||||
// 컴포넌트 import
|
||||
|
||||
interface ControlConditionStepProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 4단계: 제어 조건 설정
|
||||
* - 전체 제어가 언제 실행될지 설정
|
||||
* - INSERT/UPDATE/DELETE 트리거 조건
|
||||
*/
|
||||
const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, actions, onBack, onNext }) => {
|
||||
const { controlConditions, fromTable, toTable, fromConnection, toConnection } = state;
|
||||
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||
|
||||
// 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
console.log("🔄 ControlConditionStep 컬럼 로드 시작");
|
||||
console.log("fromConnection:", fromConnection);
|
||||
console.log("toConnection:", toConnection);
|
||||
console.log("fromTable:", fromTable);
|
||||
console.log("toTable:", toTable);
|
||||
|
||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
||||
console.log("❌ 필수 정보 누락으로 컬럼 로드 중단");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log(
|
||||
`🚀 컬럼 조회 시작: FROM=${fromConnection.id}/${fromTable.tableName}, TO=${toConnection.id}/${toTable.tableName}`,
|
||||
);
|
||||
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log(`✅ 컬럼 조회 완료: FROM=${fromCols.length}개, TO=${toCols.length}개`);
|
||||
setFromColumns(fromCols);
|
||||
setToColumns(toCols);
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||
|
||||
// 코드 타입 컬럼의 코드 로드
|
||||
useEffect(() => {
|
||||
const loadCodes = async () => {
|
||||
const allColumns = [...fromColumns, ...toColumns];
|
||||
const codeColumns = allColumns.filter(
|
||||
(col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
|
||||
);
|
||||
|
||||
if (codeColumns.length === 0) return;
|
||||
|
||||
console.log("🔍 코드 타입 컬럼들:", codeColumns);
|
||||
|
||||
const codePromises = codeColumns.map(async (col) => {
|
||||
try {
|
||||
const codes = await getCodesForColumn(col.columnName, col.webType, col.codeCategory);
|
||||
return { columnName: col.columnName, codes };
|
||||
} catch (error) {
|
||||
console.error(`코드 로딩 실패 (${col.columnName}):`, error);
|
||||
return { columnName: col.columnName, codes: [] };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(codePromises);
|
||||
const codeMap: Record<string, CodeItem[]> = {};
|
||||
|
||||
results.forEach(({ columnName, codes }) => {
|
||||
codeMap[columnName] = codes;
|
||||
});
|
||||
|
||||
console.log("📋 로딩된 코드들:", codeMap);
|
||||
setAvailableCodes(codeMap);
|
||||
};
|
||||
|
||||
if (fromColumns.length > 0 || toColumns.length > 0) {
|
||||
loadCodes();
|
||||
}
|
||||
}, [fromColumns, toColumns]);
|
||||
|
||||
// 완료 가능 여부 확인
|
||||
const canProceed =
|
||||
controlConditions.length === 0 ||
|
||||
controlConditions.some(
|
||||
(condition) =>
|
||||
condition.field &&
|
||||
condition.operator &&
|
||||
(condition.value !== "" || ["IS NULL", "IS NOT NULL"].includes(condition.operator)),
|
||||
);
|
||||
|
||||
const isCompleted = canProceed;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-orange-500" />
|
||||
)}
|
||||
4단계: 제어 실행 조건
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
이 전체 제어가 언제 실행될지 설정합니다. 조건을 설정하지 않으면 항상 실행됩니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex h-full flex-col overflow-hidden p-0">
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-4">
|
||||
{/* 제어 실행 조건 안내 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-blue-800">제어 실행 조건이란?</h4>
|
||||
<div className="space-y-1 text-sm text-blue-700">
|
||||
<p>
|
||||
• <strong>전체 제어의 트리거 조건</strong>을 설정합니다
|
||||
</p>
|
||||
<p>• 예: "상태가 '활성'이고 유형이 'A'인 경우에만 데이터 동기화 실행"</p>
|
||||
<p>• 조건을 설정하지 않으면 모든 경우에 실행됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 간단한 조건 추가 UI */}
|
||||
{!isLoading && (fromColumns.length > 0 || toColumns.length > 0 || controlConditions.length > 0) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">실행 조건 (WHERE)</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 조건 추가 클릭");
|
||||
actions.addControlCondition();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{controlConditions.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-sm">제어 실행 조건을 설정하세요</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">"조건 추가" 버튼을 클릭하여 시작하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{controlConditions.map((condition, index) => (
|
||||
<div key={`control-condition-${index}`} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 논리 연산자 */}
|
||||
{index > 0 && (
|
||||
<Select
|
||||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value) =>
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
logicalOperator: value as "AND" | "OR",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 필드 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "__placeholder__") {
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
field: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__placeholder__" disabled>
|
||||
필드 선택
|
||||
</SelectItem>
|
||||
{[...fromColumns, ...toColumns]
|
||||
.filter(
|
||||
(col, index, array) =>
|
||||
array.findIndex((c) => c.columnName === col.columnName) === index,
|
||||
)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator || "="}
|
||||
onValueChange={(value) =>
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
operator: value as any,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=">=</SelectItem>
|
||||
<SelectItem value="!=">!=</SelectItem>
|
||||
<SelectItem value=">">{">"}</SelectItem>
|
||||
<SelectItem value="<">{"<"}</SelectItem>
|
||||
<SelectItem value=">=">{">="}</SelectItem>
|
||||
<SelectItem value="<=">{`<=`}</SelectItem>
|
||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||
<SelectItem value="IS NULL">IS NULL</SelectItem>
|
||||
<SelectItem value="IS NOT NULL">IS NOT NULL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{!["IS NULL", "IS NOT NULL"].includes(condition.operator || "") &&
|
||||
(() => {
|
||||
// 선택된 필드가 코드 타입인지 확인
|
||||
const selectedField = [...fromColumns, ...toColumns].find(
|
||||
(col) => col.columnName === condition.field,
|
||||
);
|
||||
const isCodeField =
|
||||
selectedField &&
|
||||
(selectedField.webType === "code" ||
|
||||
selectedField.dataType?.toLowerCase().includes("code"));
|
||||
const fieldCodes = condition.field ? availableCodes[condition.field] : [];
|
||||
|
||||
// 디버깅 정보 출력
|
||||
console.log("🔍 값 입력 필드 디버깅:", {
|
||||
conditionField: condition.field,
|
||||
selectedField: selectedField,
|
||||
webType: selectedField?.webType,
|
||||
dataType: selectedField?.dataType,
|
||||
isCodeField: isCodeField,
|
||||
fieldCodes: fieldCodes,
|
||||
availableCodesKeys: Object.keys(availableCodes),
|
||||
});
|
||||
|
||||
if (isCodeField && fieldCodes && fieldCodes.length > 0) {
|
||||
// 코드 타입 필드면 코드 선택 드롭다운
|
||||
return (
|
||||
<Select
|
||||
value={condition.value || ""}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "__code_placeholder__") {
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__code_placeholder__" disabled>
|
||||
코드 선택
|
||||
</SelectItem>
|
||||
{fieldCodes.map((code, codeIndex) => {
|
||||
console.log("🎨 코드 렌더링:", {
|
||||
index: codeIndex,
|
||||
code: code,
|
||||
codeValue: code.code,
|
||||
codeName: code.name,
|
||||
hasCode: !!code.code,
|
||||
hasName: !!code.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={`code_${condition.field}_${code.code || codeIndex}_${codeIndex}`}
|
||||
value={code.code || `unknown_${codeIndex}`}
|
||||
>
|
||||
{code.name || code.description || `코드 ${codeIndex + 1}`}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
// 일반 필드면 텍스트 입력
|
||||
return (
|
||||
<Input
|
||||
placeholder="값"
|
||||
value={condition.value || ""}
|
||||
onChange={(e) =>
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => actions.deleteControlCondition(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조건 없음 안내 */}
|
||||
{!isLoading && controlConditions.length === 0 && (
|
||||
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<AlertCircle className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
|
||||
<h3 className="mb-2 text-lg font-medium">제어 실행 조건 없음</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
현재 제어 실행 조건이 설정되지 않았습니다.
|
||||
<br />
|
||||
모든 경우에 제어가 실행됩니다.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("제어 조건 추가 버튼 클릭");
|
||||
actions.addControlCondition();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
조건 추가하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정보 로드 실패 시 안내 */}
|
||||
{!isLoading && fromColumns.length === 0 && toColumns.length === 0 && controlConditions.length === 0 && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-yellow-800">컬럼 정보를 불러올 수 없습니다</h4>
|
||||
<div className="space-y-2 text-sm text-yellow-700">
|
||||
<p>• 외부 데이터베이스 연결에 문제가 있을 수 있습니다</p>
|
||||
<p>• 조건 없이 진행하면 항상 실행됩니다</p>
|
||||
<p>• 나중에 수동으로 조건을 추가할 수 있습니다</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 수동 조건 추가");
|
||||
actions.addControlCondition();
|
||||
}}
|
||||
className="mt-3 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
수동으로 조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 요약 */}
|
||||
{controlConditions.length > 0 && (
|
||||
<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={controlConditions.length > 0 ? "default" : "secondary"}>
|
||||
{controlConditions.length > 0 ? `${controlConditions.length}개 조건` : "조건 없음"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>실행 방식:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{controlConditions.length === 0 ? "항상 실행" : "조건부 실행"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<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" />
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 액션 설정
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlConditionStep;
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Link, Loader2, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection, TableInfo, ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import FieldMappingCanvas from "./VisualMapping/FieldMappingCanvas";
|
||||
|
||||
interface FieldMappingStepProps {
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fieldMappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 3단계: 시각적 필드 매핑
|
||||
* - SVG 기반 연결선 표시
|
||||
* - 드래그 앤 드롭 지원 (향후)
|
||||
* - 실시간 매핑 업데이트
|
||||
*/
|
||||
const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fieldMappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
onNext,
|
||||
onBack,
|
||||
}) => {
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
console.log("🔍 컬럼 로딩 시작:", {
|
||||
fromConnection: fromConnection?.id,
|
||||
toConnection: toConnection?.id,
|
||||
fromTable: fromTable?.tableName,
|
||||
toTable: toTable?.tableName,
|
||||
});
|
||||
|
||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
||||
console.warn("⚠️ 필수 정보 누락:", {
|
||||
fromConnection: !!fromConnection,
|
||||
toConnection: !!toConnection,
|
||||
fromTable: !!fromTable,
|
||||
toTable: !!toTable,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log("📡 API 호출 시작:", {
|
||||
fromAPI: `getColumnsFromConnection(${fromConnection.id}, "${fromTable.tableName}")`,
|
||||
toAPI: `getColumnsFromConnection(${toConnection.id}, "${toTable.tableName}")`,
|
||||
});
|
||||
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log("🔍 원본 API 응답 확인:", {
|
||||
fromCols: fromCols,
|
||||
toCols: toCols,
|
||||
fromType: typeof fromCols,
|
||||
toType: typeof toCols,
|
||||
fromIsArray: Array.isArray(fromCols),
|
||||
toIsArray: Array.isArray(toCols),
|
||||
});
|
||||
|
||||
// 안전한 배열 처리
|
||||
const safeFromCols = Array.isArray(fromCols) ? fromCols : [];
|
||||
const safeToCols = Array.isArray(toCols) ? toCols : [];
|
||||
|
||||
console.log("✅ 컬럼 로딩 성공:", {
|
||||
fromColumns: safeFromCols.length,
|
||||
toColumns: safeToCols.length,
|
||||
fromData: safeFromCols.slice(0, 2), // 처음 2개만 로깅
|
||||
toData: safeToCols.slice(0, 2),
|
||||
originalFromType: typeof fromCols,
|
||||
originalToType: typeof toCols,
|
||||
});
|
||||
|
||||
setFromColumns(safeFromCols);
|
||||
setToColumns(safeToCols);
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
||||
toast.error("필드 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||
<span>필드 정보를 불러오는 중...</span>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link className="h-5 w-5" />
|
||||
3단계: 컬럼 매핑
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex h-full flex-col p-0">
|
||||
{/* 매핑 캔버스 - 전체 영역 사용 */}
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
) : fromColumns.length > 0 && toColumns.length > 0 ? (
|
||||
<FieldMappingCanvas
|
||||
fromFields={fromColumns}
|
||||
toFields={toColumns}
|
||||
mappings={fieldMappings}
|
||||
onCreateMapping={onCreateMapping}
|
||||
onDeleteMapping={onDeleteMapping}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-3">
|
||||
<div className="text-muted-foreground">컬럼 정보를 찾을 수 없습니다.</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 수동 재로딩 시도");
|
||||
setFromColumns([]);
|
||||
setToColumns([]);
|
||||
// useEffect가 재실행되도록 강제 업데이트
|
||||
setIsLoading(true);
|
||||
setTimeout(() => setIsLoading(false), 100);
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 - 고정 */}
|
||||
<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" />
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 매핑 완료` : "컬럼을 선택해서 매핑하세요"}
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} disabled={fieldMappings.length === 0} className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMappingStep;
|
||||
@@ -0,0 +1,571 @@
|
||||
"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;
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
// 타입 import
|
||||
import { RightPanelProps } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import StepProgress from "./StepProgress";
|
||||
import ConnectionStep from "./ConnectionStep";
|
||||
import TableStep from "./TableStep";
|
||||
import FieldMappingStep from "./FieldMappingStep";
|
||||
import ControlConditionStep from "./ControlConditionStep";
|
||||
import ActionConfigStep from "./ActionConfigStep";
|
||||
import MultiActionConfigStep from "./MultiActionConfigStep";
|
||||
|
||||
/**
|
||||
* 🎯 우측 패널 (70% 너비)
|
||||
* - 단계별 진행 UI
|
||||
* - 연결 → 테이블 → 필드 매핑
|
||||
* - 시각적 매핑 영역
|
||||
*/
|
||||
const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
|
||||
// 완료된 단계 계산
|
||||
const completedSteps: number[] = [];
|
||||
|
||||
if (state.fromConnection && state.toConnection) {
|
||||
completedSteps.push(1);
|
||||
}
|
||||
|
||||
if (state.fromTable && state.toTable) {
|
||||
completedSteps.push(2);
|
||||
}
|
||||
|
||||
// 새로운 단계 순서에 따른 완료 조건
|
||||
const needsFieldMapping = state.actionType === "insert" || state.actionType === "upsert";
|
||||
|
||||
// 3단계: 제어 조건 (테이블 선택 후 바로 접근 가능)
|
||||
if (state.fromTable && state.toTable) {
|
||||
completedSteps.push(3);
|
||||
}
|
||||
|
||||
// 4단계: 액션 설정
|
||||
if (state.actionType) {
|
||||
completedSteps.push(4);
|
||||
}
|
||||
|
||||
// 5단계: 컬럼 매핑 (INSERT/UPSERT인 경우에만)
|
||||
if (needsFieldMapping && state.fieldMappings.length > 0) {
|
||||
completedSteps.push(5);
|
||||
}
|
||||
|
||||
const renderCurrentStep = () => {
|
||||
switch (state.currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ConnectionStep
|
||||
connectionType={state.connectionType}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
onSelectConnection={actions.selectConnection}
|
||||
onNext={() => actions.goToStep(2)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<TableStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
onSelectTable={actions.selectTable}
|
||||
onNext={() => actions.goToStep(3)} // 3단계(제어 조건)로
|
||||
onBack={() => actions.goToStep(1)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 3:
|
||||
// 3단계: 제어 조건
|
||||
return (
|
||||
<ControlConditionStep
|
||||
state={state}
|
||||
actions={actions}
|
||||
onBack={() => actions.goToStep(2)}
|
||||
onNext={() => actions.goToStep(4)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 4:
|
||||
// 4단계: 통합된 멀티 액션 설정 (제어 조건 + 액션 설정 + 컬럼 매핑)
|
||||
return (
|
||||
<MultiActionConfigStep
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
controlConditions={state.controlConditions}
|
||||
onUpdateControlCondition={actions.updateControlCondition}
|
||||
onDeleteControlCondition={actions.deleteControlCondition}
|
||||
onAddControlCondition={actions.addControlCondition}
|
||||
actionGroups={state.actionGroups}
|
||||
onUpdateActionGroup={actions.updateActionGroup}
|
||||
onDeleteActionGroup={actions.deleteActionGroup}
|
||||
onAddActionGroup={actions.addActionGroup}
|
||||
onAddActionToGroup={actions.addActionToGroup}
|
||||
onUpdateActionInGroup={actions.updateActionInGroup}
|
||||
onDeleteActionFromGroup={actions.deleteActionFromGroup}
|
||||
fieldMappings={state.fieldMappings}
|
||||
onCreateMapping={actions.createMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
onNext={() => {
|
||||
// 완료 처리 - 저장 및 상위 컴포넌트 알림
|
||||
actions.saveMappings();
|
||||
}}
|
||||
onBack={() => actions.goToStep(3)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 단계 진행 표시 */}
|
||||
<div className="bg-card/50 border-b p-3">
|
||||
<StepProgress currentStep={state.currentStep} completedSteps={completedSteps} onStepClick={actions.goToStep} />
|
||||
</div>
|
||||
|
||||
{/* 현재 단계 컨텐츠 */}
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
<Card className="flex h-full flex-col overflow-hidden">{renderCurrentStep()}</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, Circle, ArrowRight } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { StepProgressProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 📊 단계 진행 표시
|
||||
* - 현재 단계 하이라이트
|
||||
* - 완료된 단계 체크 표시
|
||||
* - 클릭으로 단계 이동
|
||||
*/
|
||||
const StepProgress: React.FC<StepProgressProps> = ({ currentStep, completedSteps, onStepClick }) => {
|
||||
const steps = [
|
||||
{ number: 1, title: "연결 선택", description: "FROM/TO 데이터베이스 연결" },
|
||||
{ number: 2, title: "테이블 선택", description: "소스/대상 테이블 선택" },
|
||||
{ number: 3, title: "제어 조건", description: "전체 제어 실행 조건 설정" },
|
||||
{ number: 4, title: "액션 및 매핑", description: "액션 설정 및 컬럼 매핑" },
|
||||
];
|
||||
|
||||
const getStepStatus = (stepNumber: number) => {
|
||||
if (completedSteps.includes(stepNumber)) return "completed";
|
||||
if (stepNumber === currentStep) return "current";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const getStepIcon = (stepNumber: number) => {
|
||||
const status = getStepStatus(stepNumber);
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Circle className={`h-5 w-5 ${status === "current" ? "text-primary fill-primary" : "text-muted-foreground"}`} />
|
||||
);
|
||||
};
|
||||
|
||||
const canClickStep = (stepNumber: number) => {
|
||||
// 현재 단계이거나 완료된 단계만 클릭 가능
|
||||
return stepNumber === currentStep || completedSteps.includes(stepNumber);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.number}>
|
||||
{/* 단계 */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex h-auto items-center gap-3 p-3 ${
|
||||
canClickStep(step.number) ? "hover:bg-muted/50 cursor-pointer" : "cursor-default"
|
||||
}`}
|
||||
onClick={() => canClickStep(step.number) && onStepClick(step.number as 1 | 2 | 3 | 4 | 5)}
|
||||
disabled={!canClickStep(step.number)}
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className="flex-shrink-0">{getStepIcon(step.number)}</div>
|
||||
|
||||
{/* 텍스트 */}
|
||||
<div className="text-left">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
getStepStatus(step.number) === "current"
|
||||
? "text-primary"
|
||||
: getStepStatus(step.number) === "completed"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{step.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 화살표 (마지막 단계 제외) */}
|
||||
{index < steps.length - 1 && <ArrowRight className="text-muted-foreground mx-2 h-4 w-4" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepProgress;
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { 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 { Input } from "@/components/ui/input";
|
||||
import { ArrowLeft, ArrowRight, Table, Search, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getTablesFromConnection, getBatchTablesWithColumns } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface TableStepProps {
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
onSelectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 2단계: 테이블 선택
|
||||
* - FROM/TO 테이블 선택
|
||||
* - 테이블 검색 기능
|
||||
* - 컬럼 수 정보 표시
|
||||
*/
|
||||
const TableStep: React.FC<TableStepProps> = ({
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fromTable,
|
||||
toTable,
|
||||
onSelectTable,
|
||||
onNext,
|
||||
onBack,
|
||||
}) => {
|
||||
const [fromTables, setFromTables] = useState<TableInfo[]>([]);
|
||||
const [toTables, setToTables] = useState<TableInfo[]>([]);
|
||||
const [fromSearch, setFromSearch] = useState("");
|
||||
const [toSearch, setToSearch] = useState("");
|
||||
const [isLoadingFrom, setIsLoadingFrom] = useState(false);
|
||||
const [isLoadingTo, setIsLoadingTo] = useState(false);
|
||||
const [tableColumnCounts, setTableColumnCounts] = useState<Record<string, number>>({});
|
||||
|
||||
// FROM 테이블 목록 로드 (배치 조회)
|
||||
useEffect(() => {
|
||||
if (fromConnection) {
|
||||
const loadFromTables = async () => {
|
||||
try {
|
||||
setIsLoadingFrom(true);
|
||||
console.log("🚀 FROM 테이블 배치 조회 시작");
|
||||
|
||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
||||
const batchResult = await getBatchTablesWithColumns(fromConnection.id);
|
||||
|
||||
console.log("✅ FROM 테이블 배치 조회 완료:", batchResult);
|
||||
|
||||
// TableInfo 형식으로 변환
|
||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
displayName: item.displayName || item.tableName,
|
||||
}));
|
||||
|
||||
setFromTables(tables);
|
||||
|
||||
// 컬럼 수 정보를 state에 저장
|
||||
const columnCounts: Record<string, number> = {};
|
||||
batchResult.forEach((item) => {
|
||||
columnCounts[`from_${item.tableName}`] = item.columnCount;
|
||||
});
|
||||
|
||||
setTableColumnCounts((prev) => ({
|
||||
...prev,
|
||||
...columnCounts,
|
||||
}));
|
||||
|
||||
console.log(`📊 FROM 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||
toast.error("소스 테이블 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingFrom(false);
|
||||
}
|
||||
};
|
||||
loadFromTables();
|
||||
}
|
||||
}, [fromConnection]);
|
||||
|
||||
// TO 테이블 목록 로드 (배치 조회)
|
||||
useEffect(() => {
|
||||
if (toConnection) {
|
||||
const loadToTables = async () => {
|
||||
try {
|
||||
setIsLoadingTo(true);
|
||||
console.log("🚀 TO 테이블 배치 조회 시작");
|
||||
|
||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
||||
const batchResult = await getBatchTablesWithColumns(toConnection.id);
|
||||
|
||||
console.log("✅ TO 테이블 배치 조회 완료:", batchResult);
|
||||
|
||||
// TableInfo 형식으로 변환
|
||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
displayName: item.displayName || item.tableName,
|
||||
}));
|
||||
|
||||
setToTables(tables);
|
||||
|
||||
// 컬럼 수 정보를 state에 저장
|
||||
const columnCounts: Record<string, number> = {};
|
||||
batchResult.forEach((item) => {
|
||||
columnCounts[`to_${item.tableName}`] = item.columnCount;
|
||||
});
|
||||
|
||||
setTableColumnCounts((prev) => ({
|
||||
...prev,
|
||||
...columnCounts,
|
||||
}));
|
||||
|
||||
console.log(`📊 TO 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 실패:", error);
|
||||
toast.error("대상 테이블 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingTo(false);
|
||||
}
|
||||
};
|
||||
loadToTables();
|
||||
}
|
||||
}, [toConnection]);
|
||||
|
||||
// 테이블 필터링
|
||||
const filteredFromTables = fromTables.filter((table) =>
|
||||
(table.displayName || table.tableName).toLowerCase().includes(fromSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const filteredToTables = toTables.filter((table) =>
|
||||
(table.displayName || table.tableName).toLowerCase().includes(toSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleTableSelect = (type: "from" | "to", tableName: string) => {
|
||||
const tables = type === "from" ? fromTables : toTables;
|
||||
const table = tables.find((t) => t.tableName === tableName);
|
||||
if (table) {
|
||||
onSelectTable(type, table);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = fromTable && toTable;
|
||||
|
||||
const renderTableItem = (table: TableInfo, type: "from" | "to") => {
|
||||
const displayName =
|
||||
table.displayName && table.displayName !== table.tableName ? table.displayName : table.tableName;
|
||||
|
||||
const columnCount = tableColumnCounts[`${type}_${table.tableName}`];
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{columnCount !== undefined ? columnCount : table.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Table className="h-5 w-5" />
|
||||
2단계: 테이블 선택
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">연결된 데이터베이스에서 소스와 대상 테이블을 선택하세요.</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* FROM 테이블 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">FROM 테이블 (소스)</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{fromConnection?.name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="테이블 검색..."
|
||||
value={fromSearch}
|
||||
onChange={(e) => setFromSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{isLoadingFrom ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={fromTable?.tableName || ""} onValueChange={(value) => handleTableSelect("from", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredFromTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{renderTableItem(table, "from")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{fromTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium">{fromTable.displayName || fromTable.tableName}</span>
|
||||
<Badge variant="secondary">
|
||||
📊 {tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
{fromTable.description && <p className="text-muted-foreground text-xs">{fromTable.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TO 테이블 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">TO 테이블 (대상)</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{toConnection?.name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="테이블 검색..."
|
||||
value={toSearch}
|
||||
onChange={(e) => setToSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{isLoadingTo ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={toTable?.tableName || ""} onValueChange={(value) => handleTableSelect("to", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredToTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{renderTableItem(table, "to")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{toTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium">{toTable.displayName || toTable.tableName}</span>
|
||||
<Badge variant="secondary">
|
||||
📊 {tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
{toTable.description && <p className="text-muted-foreground text-xs">{toTable.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 매핑 표시 */}
|
||||
{fromTable && toTable && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{fromTable.displayName || fromTable.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-primary h-5 w-5" />
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{toTable.displayName || toTable.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
💡 테이블 매핑: {fromTable.displayName || fromTable.tableName} →{" "}
|
||||
{toTable.displayName || toTable.tableName}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 네비게이션 버튼 */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전: 연결 선택
|
||||
</Button>
|
||||
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 컬럼 매핑
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableStep;
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ConnectionLineProps {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
mapping: any;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔗 SVG 연결선 컴포넌트
|
||||
* - 베지어 곡선으로 부드러운 연결선 표시
|
||||
* - 유효성에 따른 색상 변경
|
||||
* - 호버 시 삭제 버튼 표시
|
||||
*/
|
||||
const ConnectionLine: React.FC<ConnectionLineProps> = ({ id, fromX, fromY, toX, toY, isValid, mapping, onDelete }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// 베지어 곡선 제어점 계산
|
||||
const controlPointOffset = Math.abs(toX - fromX) * 0.5;
|
||||
const controlPoint1X = fromX + controlPointOffset;
|
||||
const controlPoint1Y = fromY;
|
||||
const controlPoint2X = toX - controlPointOffset;
|
||||
const controlPoint2Y = toY;
|
||||
|
||||
// 패스 생성
|
||||
const pathData = `M ${fromX} ${fromY} C ${controlPoint1X} ${controlPoint1Y}, ${controlPoint2X} ${controlPoint2Y}, ${toX} ${toY}`;
|
||||
|
||||
// 색상 결정
|
||||
const strokeColor = isValid
|
||||
? isHovered
|
||||
? "#10b981" // green-500 hover
|
||||
: "#22c55e" // green-500
|
||||
: isHovered
|
||||
? "#f97316" // orange-500 hover
|
||||
: "#fb923c"; // orange-400
|
||||
|
||||
// 중간점 계산 (삭제 버튼 위치)
|
||||
const midX = (fromX + toX) / 2;
|
||||
const midY = (fromY + toY) / 2;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 연결선 - 더 부드럽고 덜 방해되는 스타일 */}
|
||||
<path
|
||||
d={pathData}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isHovered ? "2.5" : "1.5"}
|
||||
fill="none"
|
||||
opacity={isHovered ? "0.9" : "0.6"}
|
||||
strokeDasharray="0"
|
||||
className="cursor-pointer transition-all duration-300"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{ pointerEvents: "stroke" }}
|
||||
/>
|
||||
|
||||
{/* 연결선 위의 투명한 넓은 영역 (호버 감지용) */}
|
||||
<path
|
||||
d={pathData}
|
||||
stroke="transparent"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{ pointerEvents: "stroke" }}
|
||||
/>
|
||||
|
||||
{/* 시작점 원 */}
|
||||
<circle
|
||||
cx={fromX}
|
||||
cy={fromY}
|
||||
r={isHovered ? "3.5" : "2.5"}
|
||||
fill={strokeColor}
|
||||
opacity={isHovered ? "0.9" : "0.7"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* 끝점 원 */}
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
r={isHovered ? "3.5" : "2.5"}
|
||||
fill={strokeColor}
|
||||
opacity={isHovered ? "0.9" : "0.7"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* 호버 시 삭제 버튼 */}
|
||||
{isHovered && (
|
||||
<g>
|
||||
{/* 삭제 버튼 배경 */}
|
||||
<circle
|
||||
cx={midX}
|
||||
cy={midY}
|
||||
r="12"
|
||||
fill="white"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="2"
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{ pointerEvents: "all" }}
|
||||
/>
|
||||
|
||||
{/* X 아이콘 */}
|
||||
<g
|
||||
transform={`translate(${midX - 4}, ${midY - 4})`}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{ pointerEvents: "all" }}
|
||||
>
|
||||
<path d="M1 1L7 7M7 1L1 7" stroke={strokeColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* 매핑 정보 툴팁 (호버 시) */}
|
||||
{isHovered && (
|
||||
<g>
|
||||
<rect
|
||||
x={midX - 60}
|
||||
y={midY - 35}
|
||||
width="120"
|
||||
height="20"
|
||||
rx="4"
|
||||
fill="rgba(0, 0, 0, 0.8)"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
<text x={midX} y={midY - 22} textAnchor="middle" fill="white" fontSize="10" style={{ pointerEvents: "none" }}>
|
||||
{mapping.fromField.webType} → {mapping.toField.webType}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionLine;
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link, GripVertical } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface FieldColumnProps {
|
||||
fields: ColumnInfo[];
|
||||
type: "from" | "to";
|
||||
selectedField: ColumnInfo | null;
|
||||
onFieldSelect: (field: ColumnInfo | null) => void;
|
||||
onFieldPositionUpdate: (fieldId: string, element: HTMLElement) => void;
|
||||
isFieldMapped: (field: ColumnInfo, type: "from" | "to") => boolean;
|
||||
onDragStart?: (field: ColumnInfo) => void;
|
||||
onDragEnd?: () => void;
|
||||
onDrop?: (targetField: ColumnInfo, sourceField: ColumnInfo) => void;
|
||||
isDragOver?: boolean;
|
||||
draggedField?: ColumnInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 필드 컬럼 컴포넌트
|
||||
* - 필드 목록 표시
|
||||
* - 선택 상태 관리
|
||||
* - 위치 정보 업데이트
|
||||
*/
|
||||
const FieldColumn: React.FC<FieldColumnProps> = ({
|
||||
fields,
|
||||
type,
|
||||
selectedField,
|
||||
onFieldSelect,
|
||||
onFieldPositionUpdate,
|
||||
isFieldMapped,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
isDragOver,
|
||||
draggedField,
|
||||
}) => {
|
||||
const fieldRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
|
||||
// 필드 위치 업데이트
|
||||
useEffect(() => {
|
||||
const updatePositions = () => {
|
||||
Object.entries(fieldRefs.current).forEach(([fieldId, element]) => {
|
||||
if (element) {
|
||||
onFieldPositionUpdate(fieldId, element);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 약간의 지연을 두어 DOM이 완전히 렌더링된 후 위치 업데이트
|
||||
const timeoutId = setTimeout(updatePositions, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [fields.length]); // fields 배열 대신 length만 의존성으로 사용
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragStart = (e: React.DragEvent, field: ColumnInfo) => {
|
||||
if (type === "from" && onDragStart) {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify(field));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
onDragStart(field);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
if (onDragEnd) {
|
||||
onDragEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (type === "to") {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetField: ColumnInfo) => {
|
||||
if (type === "to" && onDrop) {
|
||||
e.preventDefault();
|
||||
|
||||
// 이미 매핑된 TO 필드인지 확인
|
||||
const isMapped = isFieldMapped(targetField, "to");
|
||||
if (isMapped) {
|
||||
// 이미 매핑된 필드에는 드롭할 수 없음을 시각적으로 표시
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceFieldData = e.dataTransfer.getData("text/plain");
|
||||
const sourceField = JSON.parse(sourceFieldData) as ColumnInfo;
|
||||
onDrop(targetField, sourceField);
|
||||
} catch (error) {
|
||||
console.error("드롭 처리 중 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 렌더링
|
||||
const renderField = (field: ColumnInfo, index: number) => {
|
||||
const fieldId = `${type}_${field.columnName}`;
|
||||
const isSelected = selectedField?.columnName === field.columnName;
|
||||
const isMapped = isFieldMapped(field, type);
|
||||
const displayName = field.displayName || field.columnName;
|
||||
const isDragging = draggedField?.columnName === field.columnName;
|
||||
const isDropTarget = type === "to" && isDragOver && draggedField && !isMapped;
|
||||
const isBlockedDropTarget = type === "to" && isDragOver && draggedField && isMapped;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${type}_${field.columnName}_${index}`}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
fieldRefs.current[fieldId] = el;
|
||||
}
|
||||
}}
|
||||
className={`relative cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/20 scale-105 transform opacity-50 shadow-lg"
|
||||
: isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: isMapped
|
||||
? "border-green-500 bg-green-50 shadow-sm"
|
||||
: isBlockedDropTarget
|
||||
? "border-red-400 bg-red-50 shadow-md"
|
||||
: isDropTarget
|
||||
? "border-blue-400 bg-blue-50 shadow-md"
|
||||
: "border-border hover:bg-muted/50 hover:shadow-sm"
|
||||
} `}
|
||||
draggable={type === "from" && !isMapped}
|
||||
onDragStart={(e) => handleDragStart(e, field)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
onClick={() => onFieldSelect(isSelected ? null : field)}
|
||||
>
|
||||
{/* 연결점 표시 */}
|
||||
<div
|
||||
className={`absolute ${type === "from" ? "right-0" : "left-0"} top-1/2 h-3 w-3 -translate-y-1/2 transform rounded-full border-2 transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary border-primary"
|
||||
: isMapped
|
||||
? "border-green-500 bg-green-500"
|
||||
: "border-gray-300 bg-white"
|
||||
} `}
|
||||
style={{
|
||||
[type === "from" ? "right" : "left"]: "-6px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{type === "from" && !isMapped && <GripVertical className="h-3 w-3 flex-shrink-0 text-gray-400" />}
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
{isMapped && <Link className="h-3 w-3 flex-shrink-0 text-green-600" />}
|
||||
</div>
|
||||
<Badge variant="outline" className="flex-shrink-0 text-xs">
|
||||
{field.webType || field.dataType || "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{field.description && <p className="text-muted-foreground mt-1 truncate text-xs">{field.description}</p>}
|
||||
|
||||
{/* 선택 상태 표시 */}
|
||||
{isSelected && <div className="border-primary pointer-events-none absolute inset-0 rounded-lg border-2" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ScrollArea className="h-full rounded-lg border">
|
||||
<div className="space-y-2 p-2">
|
||||
{fields.map((field, index) => renderField(field, index))}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
<p>필드가 없습니다.</p>
|
||||
<p className="mt-1 text-xs">테이블을 선택해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldColumn;
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Link, Unlink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { FieldMapping, FieldMappingCanvasProps } from "../../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import FieldColumn from "./FieldColumn";
|
||||
import MappingControls from "./MappingControls";
|
||||
|
||||
/**
|
||||
* 🎨 시각적 필드 매핑 캔버스
|
||||
* - SVG 기반 연결선 표시
|
||||
* - 드래그 앤 드롭 지원 (향후)
|
||||
* - 실시간 연결선 업데이트
|
||||
*/
|
||||
const FieldMappingCanvas: React.FC<FieldMappingCanvasProps> = ({
|
||||
fromFields,
|
||||
toFields,
|
||||
mappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
}) => {
|
||||
const [fromSearch, setFromSearch] = useState("");
|
||||
const [toSearch, setToSearch] = useState("");
|
||||
const [selectedFromField, setSelectedFromField] = useState<ColumnInfo | null>(null);
|
||||
const [selectedToField, setSelectedToField] = useState<ColumnInfo | null>(null);
|
||||
const [fieldPositions, setFieldPositions] = useState<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// 드래그 앤 드롭 상태
|
||||
const [draggedField, setDraggedField] = useState<ColumnInfo | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const fromColumnRef = useRef<HTMLDivElement>(null);
|
||||
const toColumnRef = useRef<HTMLDivElement>(null);
|
||||
const fieldRefs = useRef<Record<string, HTMLElement>>({});
|
||||
|
||||
// 필드 필터링 - 안전한 배열 처리
|
||||
const safeFromFields = Array.isArray(fromFields) ? fromFields : [];
|
||||
const safeToFields = Array.isArray(toFields) ? toFields : [];
|
||||
|
||||
const filteredFromFields = safeFromFields.filter((field) => {
|
||||
const fieldName = field.displayName || field.columnName || "";
|
||||
return fieldName.toLowerCase().includes(fromSearch.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredToFields = safeToFields.filter((field) => {
|
||||
const fieldName = field.displayName || field.columnName || "";
|
||||
return fieldName.toLowerCase().includes(toSearch.toLowerCase());
|
||||
});
|
||||
|
||||
// 매핑 생성
|
||||
const handleCreateMapping = useCallback(() => {
|
||||
if (selectedFromField && selectedToField) {
|
||||
// 안전한 매핑 배열 처리
|
||||
const safeMappings = Array.isArray(mappings) ? mappings : [];
|
||||
|
||||
// N:1 매핑 방지 - TO 필드가 이미 매핑되어 있는지 확인
|
||||
const existingToMapping = safeMappings.find((m) => m.toField.columnName === selectedToField.columnName);
|
||||
|
||||
if (existingToMapping) {
|
||||
toast.error(
|
||||
`대상 필드 '${selectedToField.displayName || selectedToField.columnName}'는 이미 매핑되어 있습니다.\nN:1 매핑은 허용되지 않습니다.`,
|
||||
);
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 동일한 매핑 중복 체크
|
||||
const existingMapping = safeMappings.find(
|
||||
(m) =>
|
||||
m.fromField.columnName === selectedFromField.columnName &&
|
||||
m.toField.columnName === selectedToField.columnName,
|
||||
);
|
||||
|
||||
if (existingMapping) {
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateMapping(selectedFromField, selectedToField);
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
}
|
||||
}, [selectedFromField, selectedToField, mappings, onCreateMapping]);
|
||||
|
||||
// 드래그 앤 드롭 핸들러들
|
||||
const handleDragStart = useCallback((field: ColumnInfo) => {
|
||||
setDraggedField(field);
|
||||
setSelectedFromField(field); // 드래그 시작 시 선택 상태로 표시
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
// 드래그 오버 상태 관리
|
||||
useEffect(() => {
|
||||
if (draggedField) {
|
||||
setIsDragOver(true);
|
||||
} else {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, [draggedField]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(targetField: ColumnInfo, sourceField: ColumnInfo) => {
|
||||
// 안전한 매핑 배열 처리
|
||||
const safeMappings = Array.isArray(mappings) ? mappings : [];
|
||||
|
||||
// N:1 매핑 방지 - TO 필드가 이미 매핑되어 있는지 확인
|
||||
const existingToMapping = safeMappings.find((m) => m.toField.columnName === targetField.columnName);
|
||||
|
||||
if (existingToMapping) {
|
||||
toast.error(
|
||||
`대상 필드 '${targetField.displayName || targetField.columnName}'는 이미 매핑되어 있습니다.\nN:1 매핑은 허용되지 않습니다.`,
|
||||
);
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 동일한 매핑 중복 체크
|
||||
const existingMapping = mappings.find(
|
||||
(m) => m.fromField.columnName === sourceField.columnName && m.toField.columnName === targetField.columnName,
|
||||
);
|
||||
|
||||
if (existingMapping) {
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 생성
|
||||
onCreateMapping(sourceField, targetField);
|
||||
|
||||
// 상태 초기화
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
},
|
||||
[mappings, onCreateMapping],
|
||||
);
|
||||
|
||||
// 필드 위치 업데이트 (메모이제이션)
|
||||
const updateFieldPosition = useCallback((fieldId: string, element: HTMLElement) => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// fieldRefs에 저장
|
||||
fieldRefs.current[fieldId] = element;
|
||||
|
||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||
const fieldRect = element.getBoundingClientRect();
|
||||
|
||||
const x = fieldRect.left - canvasRect.left + fieldRect.width / 2;
|
||||
const y = fieldRect.top - canvasRect.top + fieldRect.height / 2;
|
||||
|
||||
setFieldPositions((prev) => {
|
||||
// 위치가 실제로 변경된 경우에만 업데이트
|
||||
const currentPos = prev[fieldId];
|
||||
if (currentPos && Math.abs(currentPos.x - x) < 1 && Math.abs(currentPos.y - y) < 1) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[fieldId]: { x, y },
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너로 연결선 위치 업데이트
|
||||
useEffect(() => {
|
||||
const updatePositionsOnScroll = () => {
|
||||
// 모든 필드의 위치를 다시 계산
|
||||
Object.entries(fieldRefs.current || {}).forEach(([fieldId, element]) => {
|
||||
if (element) {
|
||||
updateFieldPosition(fieldId, element);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 스크롤 가능한 영역들에 이벤트 리스너 추가
|
||||
const scrollAreas = document.querySelectorAll("[data-radix-scroll-area-viewport]");
|
||||
|
||||
scrollAreas.forEach((area) => {
|
||||
area.addEventListener("scroll", updatePositionsOnScroll, { passive: true });
|
||||
});
|
||||
|
||||
// 윈도우 리사이즈 시에도 위치 업데이트
|
||||
window.addEventListener("resize", updatePositionsOnScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollAreas.forEach((area) => {
|
||||
area.removeEventListener("scroll", updatePositionsOnScroll);
|
||||
});
|
||||
window.removeEventListener("resize", updatePositionsOnScroll);
|
||||
};
|
||||
}, [updateFieldPosition]);
|
||||
|
||||
// 매핑 여부 확인
|
||||
const isFieldMapped = useCallback(
|
||||
(field: ColumnInfo, type: "from" | "to") => {
|
||||
return mappings.some((mapping) =>
|
||||
type === "from"
|
||||
? mapping.fromField.columnName === field.columnName
|
||||
: mapping.toField.columnName === field.columnName,
|
||||
);
|
||||
},
|
||||
[mappings],
|
||||
);
|
||||
|
||||
// 연결선 데이터 생성
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} className="relative flex h-full flex-col">
|
||||
{/* 매핑 생성 컨트롤 */}
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<MappingControls
|
||||
selectedFromField={selectedFromField}
|
||||
selectedToField={selectedToField}
|
||||
onCreateMapping={handleCreateMapping}
|
||||
canCreate={!!(selectedFromField && selectedToField)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 영역 */}
|
||||
<div className="grid max-h-[500px] min-h-[300px] flex-1 grid-cols-2 gap-6 overflow-hidden">
|
||||
{/* FROM 필드 컬럼 */}
|
||||
<div ref={fromColumnRef} className="flex h-full flex-col">
|
||||
<div className="mb-3 flex flex-shrink-0 items-center justify-between">
|
||||
<h3 className="font-medium">FROM 필드</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{filteredFromFields.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3 flex-shrink-0">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={fromSearch}
|
||||
onChange={(e) => setFromSearch(e.target.value)}
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] min-h-0 flex-1">
|
||||
<FieldColumn
|
||||
fields={filteredFromFields}
|
||||
type="from"
|
||||
selectedField={selectedFromField}
|
||||
onFieldSelect={setSelectedFromField}
|
||||
onFieldPositionUpdate={updateFieldPosition}
|
||||
isFieldMapped={isFieldMapped}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
draggedField={draggedField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TO 필드 컬럼 */}
|
||||
<div ref={toColumnRef} className="flex h-full flex-col">
|
||||
<div className="mb-3 flex flex-shrink-0 items-center justify-between">
|
||||
<h3 className="font-medium">TO 필드</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{filteredToFields.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3 flex-shrink-0">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={toSearch}
|
||||
onChange={(e) => setToSearch(e.target.value)}
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] min-h-0 flex-1">
|
||||
<FieldColumn
|
||||
fields={filteredToFields}
|
||||
type="to"
|
||||
selectedField={selectedToField}
|
||||
onFieldSelect={setSelectedToField}
|
||||
onFieldPositionUpdate={updateFieldPosition}
|
||||
isFieldMapped={isFieldMapped}
|
||||
onDrop={handleDrop}
|
||||
isDragOver={isDragOver}
|
||||
draggedField={draggedField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 규칙 안내 */}
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<h4 className="mb-2 text-sm font-medium">📋 매핑 규칙</h4>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>✅ 1:N 매핑 허용 (하나의 소스 필드를 여러 대상 필드에 매핑)</p>
|
||||
<p>❌ N:1 매핑 금지 (여러 소스 필드를 하나의 대상 필드에 매핑 불가)</p>
|
||||
<p>🔒 이미 매핑된 대상 필드는 추가 매핑이 차단됩니다</p>
|
||||
<p>🔗 {mappings.length}개 연결됨</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMappingCanvas;
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link, ArrowRight, MousePointer, Move } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface MappingControlsProps {
|
||||
selectedFromField: ColumnInfo | null;
|
||||
selectedToField: ColumnInfo | null;
|
||||
onCreateMapping: () => void;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 매핑 생성 컨트롤
|
||||
* - 선택된 필드 표시
|
||||
* - 매핑 생성 버튼
|
||||
* - 시각적 피드백
|
||||
*/
|
||||
const MappingControls: React.FC<MappingControlsProps> = ({
|
||||
selectedFromField,
|
||||
selectedToField,
|
||||
onCreateMapping,
|
||||
canCreate,
|
||||
}) => {
|
||||
// 안내 메시지 표시 여부
|
||||
const showGuidance = !selectedFromField && !selectedToField;
|
||||
|
||||
if (showGuidance) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-lg border p-4">
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<MousePointer className="h-4 w-4" />
|
||||
<span>클릭으로 선택</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">또는</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Move className="h-4 w-4" />
|
||||
<span>드래그 앤 드롭으로 매핑</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">선택된 필드:</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{/* FROM 필드 */}
|
||||
<Badge
|
||||
variant={selectedFromField ? "default" : "outline"}
|
||||
className={`transition-all ${selectedFromField ? "shadow-sm" : ""}`}
|
||||
>
|
||||
FROM: {selectedFromField?.displayName || selectedFromField?.columnName || "없음"}
|
||||
</Badge>
|
||||
|
||||
{/* 화살표 */}
|
||||
<ArrowRight
|
||||
className={`h-4 w-4 transition-colors ${canCreate ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* TO 필드 */}
|
||||
<Badge
|
||||
variant={selectedToField ? "default" : "outline"}
|
||||
className={`transition-all ${selectedToField ? "shadow-sm" : ""}`}
|
||||
>
|
||||
TO: {selectedToField?.displayName || selectedToField?.columnName || "없음"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입 호환성 표시 */}
|
||||
{selectedFromField && selectedToField && (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">타입:</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedFromField.webType || "unknown"}
|
||||
</Badge>
|
||||
<ArrowRight className="text-muted-foreground h-3 w-3" />
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedToField.webType || "unknown"}
|
||||
</Badge>
|
||||
{/* 타입 호환성 아이콘 */}
|
||||
{selectedFromField.webType === selectedToField.webType ? (
|
||||
<span className="text-xs text-green-600">✅</span>
|
||||
) : (
|
||||
<span className="text-xs text-orange-600">⚠️</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 생성 버튼 */}
|
||||
<Button
|
||||
onClick={onCreateMapping}
|
||||
disabled={!canCreate}
|
||||
size="sm"
|
||||
className={`transition-all ${canCreate ? "shadow-sm hover:shadow-md" : ""}`}
|
||||
>
|
||||
<Link className="mr-1 h-4 w-4" />
|
||||
매핑 생성
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MappingControls;
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckCircle, Save } from "lucide-react";
|
||||
|
||||
interface SaveRelationshipDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (relationshipName: string, description?: string) => void;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
fromTable?: string;
|
||||
toTable?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 💾 관계 저장 다이얼로그
|
||||
* - 관계 이름 입력
|
||||
* - 설명 입력 (선택사항)
|
||||
* - 액션 타입별 제안 이름
|
||||
*/
|
||||
const SaveRelationshipDialog: React.FC<SaveRelationshipDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
actionType,
|
||||
fromTable,
|
||||
toTable,
|
||||
}) => {
|
||||
const [relationshipName, setRelationshipName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// 액션 타입별 제안 이름 생성
|
||||
const generateSuggestedName = () => {
|
||||
if (!fromTable || !toTable) return "";
|
||||
|
||||
const actionMap = {
|
||||
insert: "입력",
|
||||
update: "수정",
|
||||
delete: "삭제",
|
||||
upsert: "병합",
|
||||
};
|
||||
|
||||
return `${fromTable}_${toTable}_${actionMap[actionType]}`;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!relationshipName.trim()) return;
|
||||
|
||||
onSave(relationshipName.trim(), description.trim() || undefined);
|
||||
onOpenChange(false);
|
||||
|
||||
// 폼 초기화
|
||||
setRelationshipName("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const handleSuggestName = () => {
|
||||
const suggested = generateSuggestedName();
|
||||
if (suggested) {
|
||||
setRelationshipName(suggested);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Save className="h-5 w-5" />
|
||||
관계 저장
|
||||
</DialogTitle>
|
||||
<DialogDescription>데이터 연결 관계의 이름과 설명을 입력하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 관계 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relationshipName">관계 이름 *</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="relationshipName"
|
||||
placeholder="예: 사용자_주문_입력"
|
||||
value={relationshipName}
|
||||
onChange={(e) => setRelationshipName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestName} disabled={!fromTable || !toTable}>
|
||||
제안
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설명 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명 (선택사항)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="이 관계에 대한 설명을 입력하세요..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정보 요약 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">액션 타입:</span>
|
||||
<span className="font-medium">{actionType.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">소스 테이블:</span>
|
||||
<span className="font-medium">{fromTable || "미선택"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">대상 테이블:</span>
|
||||
<span className="font-medium">{toTable || "미선택"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!relationshipName.trim()}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveRelationshipDialog;
|
||||
@@ -0,0 +1,209 @@
|
||||
// 🎨 제어관리 UI 재설계 - 타입 정의
|
||||
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 연결 타입
|
||||
export interface ConnectionType {
|
||||
id: "data_save" | "external_call";
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
// 필드 매핑
|
||||
export interface FieldMapping {
|
||||
id: string;
|
||||
fromField: ColumnInfo;
|
||||
toField: ColumnInfo;
|
||||
transformRule?: string;
|
||||
isValid: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
// 시각적 연결선
|
||||
export interface MappingLine {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
// 매핑 통계
|
||||
export interface MappingStats {
|
||||
totalMappings: number;
|
||||
validMappings: number;
|
||||
invalidMappings: number;
|
||||
missingRequiredFields: number;
|
||||
estimatedRows: number;
|
||||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||
}
|
||||
|
||||
// 검증 결과
|
||||
export interface ValidationError {
|
||||
id: string;
|
||||
type: "error" | "warning" | "info";
|
||||
message: string;
|
||||
fieldId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
}
|
||||
|
||||
// 테스트 결과
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
affectedRows?: number;
|
||||
executionTime?: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// 단일 액션 정의
|
||||
export interface SingleAction {
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
conditions: any[];
|
||||
fieldMappings: any[];
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// 액션 그룹 (AND/OR 조건으로 연결)
|
||||
export interface ActionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
logicalOperator: "AND" | "OR";
|
||||
actions: SingleAction[];
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// 메인 상태
|
||||
export interface DataConnectionState {
|
||||
// 기본 설정
|
||||
connectionType: "data_save" | "external_call";
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
|
||||
// 연결 정보
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
|
||||
// 매핑 정보
|
||||
fieldMappings: FieldMapping[];
|
||||
mappingStats: MappingStats;
|
||||
|
||||
// 제어 실행 조건 (전체 제어가 언제 실행될지)
|
||||
controlConditions: any[]; // 전체 제어 트리거 조건
|
||||
|
||||
// 액션 설정 (멀티 액션 지원)
|
||||
actionGroups: ActionGroup[];
|
||||
|
||||
// 기존 호환성을 위한 필드들 (deprecated)
|
||||
actionType?: "insert" | "update" | "delete" | "upsert";
|
||||
actionConditions?: any[]; // 각 액션의 대상 레코드 조건
|
||||
actionFieldMappings?: any[]; // 액션별 필드 매핑
|
||||
|
||||
// UI 상태
|
||||
selectedMapping?: string;
|
||||
isLoading: boolean;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
// 액션 인터페이스
|
||||
export interface DataConnectionActions {
|
||||
// 연결 타입
|
||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||
|
||||
// 단계 진행
|
||||
goToStep: (step: 1 | 2 | 3 | 4) => void;
|
||||
|
||||
// 연결/테이블 선택
|
||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
|
||||
// 필드 매핑
|
||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
deleteMapping: (mappingId: string) => void;
|
||||
|
||||
// 제어 조건 관리 (전체 실행 조건)
|
||||
addControlCondition: () => void;
|
||||
updateControlCondition: (index: number, condition: any) => void;
|
||||
deleteControlCondition: (index: number) => void;
|
||||
|
||||
// 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: () => void;
|
||||
updateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
||||
deleteActionGroup: (groupId: string) => void;
|
||||
addActionToGroup: (groupId: string) => void;
|
||||
updateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
||||
deleteActionFromGroup: (groupId: string, actionId: string) => void;
|
||||
|
||||
// 기존 액션 설정 (호환성)
|
||||
setActionType: (type: "insert" | "update" | "delete" | "upsert") => void;
|
||||
addActionCondition: () => void;
|
||||
updateActionCondition: (index: number, condition: any) => void;
|
||||
setActionConditions: (conditions: any[]) => void; // 액션 조건 배열 전체 업데이트
|
||||
deleteActionCondition: (index: number) => void;
|
||||
|
||||
// 검증 및 저장
|
||||
validateMappings: () => Promise<ValidationResult>;
|
||||
saveMappings: () => Promise<void>;
|
||||
testExecution: () => Promise<TestResult>;
|
||||
}
|
||||
|
||||
// 컴포넌트 Props 타입들
|
||||
export interface DataConnectionDesignerProps {
|
||||
onClose?: () => void;
|
||||
initialData?: Partial<DataConnectionState>;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
export interface LeftPanelProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
export interface RightPanelProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
export interface ConnectionTypeSelectorProps {
|
||||
selectedType: "data_save" | "external_call";
|
||||
onTypeChange: (type: "data_save" | "external_call") => void;
|
||||
}
|
||||
|
||||
export interface MappingInfoPanelProps {
|
||||
stats: MappingStats;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
export interface MappingDetailListProps {
|
||||
mappings: FieldMapping[];
|
||||
selectedMapping?: string;
|
||||
onSelectMapping: (mappingId: string) => void;
|
||||
onUpdateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
}
|
||||
|
||||
export interface StepProgressProps {
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
completedSteps: number[];
|
||||
onStepClick: (step: 1 | 2 | 3 | 4) => void;
|
||||
}
|
||||
|
||||
export interface FieldMappingCanvasProps {
|
||||
fromFields: ColumnInfo[];
|
||||
toFields: ColumnInfo[];
|
||||
mappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user