제어관리 외부커넥션 설정기능

This commit is contained in:
kjs
2025-09-26 01:28:51 +09:00
parent 1a59c0cf04
commit 2a4e379dc4
43 changed files with 7129 additions and 316 deletions

View File

@@ -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" />

View File

@@ -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",
}}
/>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}