ERP-node/frontend/components/dataflow/ConnectionSetupModal.tsx

1622 lines
76 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
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 { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Link, Key, Save, Globe, Plus, Zap, Trash2 } from "lucide-react";
import { DataFlowAPI, TableRelationship, TableInfo, ColumnInfo, ConditionNode } from "@/lib/api/dataflow";
import toast from "react-hot-toast";
// 연결 정보 타입
interface ConnectionInfo {
fromNode: {
id: string;
tableName: string;
displayName: string;
};
toNode: {
id: string;
tableName: string;
displayName: string;
};
fromColumn?: string;
toColumn?: string;
selectedColumnsData?: {
[tableName: string]: {
displayName: string;
columns: string[];
};
};
existingRelationship?: {
relationshipName: string;
relationshipType: string;
connectionType: string;
settings?: Record<string, unknown>;
};
}
// 연결 설정 타입
interface ConnectionConfig {
relationshipName: string;
relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
connectionType: "simple-key" | "data-save" | "external-call";
fromColumnName: string;
toColumnName: string;
settings?: Record<string, unknown>;
description?: string;
}
// 단순 키값 연결 설정
interface SimpleKeySettings {
notes: string;
}
// 데이터 저장 설정
interface DataSaveSettings {
actions: Array<{
id: string;
name: string;
actionType: "insert" | "update" | "delete" | "upsert";
conditions?: Array<{
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value: string;
logicalOperator?: "AND" | "OR";
}>;
fieldMappings: Array<{
sourceTable?: string;
sourceField: string;
targetTable?: string;
targetField: string;
defaultValue?: string;
transformFunction?: string;
}>;
splitConfig?: {
sourceField: string; // 분할할 소스 필드
delimiter: string; // 구분자 (예: ",")
targetField: string; // 분할된 값이 들어갈 필드
};
}>;
}
// 외부 호출 설정
interface ExternalCallSettings {
callType: "rest-api" | "email" | "webhook" | "ftp" | "queue";
apiUrl?: string;
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
headers?: string;
bodyTemplate?: string;
}
interface ConnectionSetupModalProps {
isOpen: boolean;
connection: ConnectionInfo | null;
companyCode: string;
onConfirm: (relationship: TableRelationship) => void;
onCancel: () => void;
}
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
isOpen,
connection,
companyCode,
onConfirm,
onCancel,
}) => {
const [config, setConfig] = useState<ConnectionConfig>({
relationshipName: "",
relationshipType: "one-to-one",
connectionType: "simple-key",
fromColumnName: "",
toColumnName: "",
description: "",
settings: {},
});
// 연결 종류별 설정 상태
const [simpleKeySettings, setSimpleKeySettings] = useState<SimpleKeySettings>({
notes: "",
});
const [dataSaveSettings, setDataSaveSettings] = useState<DataSaveSettings>({
actions: [],
});
const [externalCallSettings, setExternalCallSettings] = useState<ExternalCallSettings>({
callType: "rest-api",
apiUrl: "",
httpMethod: "POST",
headers: "{}",
bodyTemplate: "{}",
});
// 테이블 및 컬럼 선택을 위한 새로운 상태들
const [availableTables, setAvailableTables] = useState<TableInfo[]>([]);
const [selectedFromTable, setSelectedFromTable] = useState<string>("");
const [selectedToTable, setSelectedToTable] = useState<string>("");
const [fromTableColumns, setFromTableColumns] = useState<ColumnInfo[]>([]);
const [toTableColumns, setToTableColumns] = useState<ColumnInfo[]>([]);
const [selectedFromColumns, setSelectedFromColumns] = useState<string[]>([]);
const [selectedToColumns, setSelectedToColumns] = useState<string[]>([]);
// 필요시 로드하는 테이블 컬럼 캐시
const [tableColumnsCache, setTableColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
// 조건부 연결을 위한 새로운 상태들
const [conditions, setConditions] = useState<ConditionNode[]>([]);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
try {
const tables = await DataFlowAPI.getTables();
setAvailableTables(tables);
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
};
if (isOpen) {
loadTables();
}
}, [isOpen]);
// 모달이 열릴 때 기본값 설정
useEffect(() => {
if (isOpen && connection) {
const fromTableName = connection.fromNode.tableName;
const toTableName = connection.toNode.tableName;
const fromDisplayName = connection.fromNode.displayName;
const toDisplayName = connection.toNode.displayName;
// 테이블 선택 설정
setSelectedFromTable(fromTableName);
setSelectedToTable(toTableName);
// 기존 관계 정보가 있으면 사용, 없으면 기본값 설정
const existingRel = connection.existingRelationship;
setConfig({
relationshipName: existingRel?.relationshipName || `${fromDisplayName}${toDisplayName}`,
relationshipType:
(existingRel?.relationshipType as "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many") ||
"one-to-one",
connectionType: (existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key",
fromColumnName: "",
toColumnName: "",
description:
(existingRel?.settings?.description as string) || `${fromDisplayName}${toDisplayName} 간의 데이터 관계`,
settings: existingRel?.settings || {},
});
// 단순 키값 연결 기본값 설정
setSimpleKeySettings({
notes: `${fromDisplayName}${toDisplayName} 간의 키값 연결`,
});
// 데이터 저장 기본값 설정 (빈 배열로 시작)
setDataSaveSettings({
actions: [],
});
// 외부 호출 기본값 설정
setExternalCallSettings({
callType: "rest-api",
apiUrl: "https://api.example.com/webhook",
httpMethod: "POST",
headers: "{}",
bodyTemplate: "{}",
});
// 선택된 컬럼 정보가 있다면 설정
if (connection.selectedColumnsData) {
const fromColumns = connection.selectedColumnsData[fromTableName]?.columns || [];
const toColumns = connection.selectedColumnsData[toTableName]?.columns || [];
setSelectedFromColumns(fromColumns);
setSelectedToColumns(toColumns);
setConfig((prev) => ({
...prev,
fromColumnName: fromColumns.join(", "),
toColumnName: toColumns.join(", "),
}));
}
}
}, [isOpen, connection]);
// From 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadFromColumns = async () => {
if (selectedFromTable) {
try {
const columns = await DataFlowAPI.getTableColumns(selectedFromTable);
setFromTableColumns(columns);
} catch (error) {
console.error("From 테이블 컬럼 로드 실패:", error);
toast.error("From 테이블 컬럼을 불러오는데 실패했습니다.");
}
}
};
loadFromColumns();
}, [selectedFromTable]);
// To 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadToColumns = async () => {
if (selectedToTable) {
try {
const columns = await DataFlowAPI.getTableColumns(selectedToTable);
setToTableColumns(columns);
} catch (error) {
console.error("To 테이블 컬럼 로드 실패:", error);
toast.error("To 테이블 컬럼을 불러오는데 실패했습니다.");
}
}
};
loadToColumns();
}, [selectedToTable]);
// 선택된 컬럼들이 변경될 때 config 업데이트
useEffect(() => {
setConfig((prev) => ({
...prev,
fromColumnName: selectedFromColumns.join(", "),
toColumnName: selectedToColumns.join(", "),
}));
}, [selectedFromColumns, selectedToColumns]);
// 테이블 컬럼 로드 함수 (캐시 활용)
const loadTableColumns = async (tableName: string): Promise<ColumnInfo[]> => {
if (tableColumnsCache[tableName]) {
return tableColumnsCache[tableName];
}
try {
const columns = await DataFlowAPI.getTableColumns(tableName);
setTableColumnsCache((prev) => ({
...prev,
[tableName]: columns,
}));
return columns;
} catch (error) {
console.error(`${tableName} 컬럼 로드 실패:`, error);
return [];
}
};
// 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
const tablesToLoad = new Set<string>();
// 필드 매핑에서 사용되는 모든 테이블 수집
dataSaveSettings.actions.forEach((action) => {
action.fieldMappings.forEach((mapping) => {
if (mapping.sourceTable && !tableColumnsCache[mapping.sourceTable]) {
tablesToLoad.add(mapping.sourceTable);
}
if (mapping.targetTable && !tableColumnsCache[mapping.targetTable]) {
tablesToLoad.add(mapping.targetTable);
}
});
});
// 필요한 테이블들의 컬럼만 로드
for (const tableName of tablesToLoad) {
await loadTableColumns(tableName);
}
};
loadColumns();
}, [dataSaveSettings.actions, tableColumnsCache]); // eslint-disable-line react-hooks/exhaustive-deps
const handleConfirm = () => {
if (!config.relationshipName || !connection) {
toast.error("필수 정보를 모두 입력해주세요.");
return;
}
// 연결 종류별 설정을 준비
let settings = {};
switch (config.connectionType) {
case "simple-key":
settings = simpleKeySettings;
break;
case "data-save":
settings = dataSaveSettings;
break;
case "external-call":
settings = externalCallSettings;
break;
}
// 선택된 컬럼들 검증
if (selectedFromColumns.length === 0 || selectedToColumns.length === 0) {
toast.error("선택된 컬럼이 없습니다. From과 To 테이블에서 각각 최소 1개 이상의 컬럼을 선택해주세요.");
return;
}
// 선택된 테이블과 컬럼 정보 사용
const fromTableName = selectedFromTable || connection.fromNode.tableName;
const toTableName = selectedToTable || connection.toNode.tableName;
// 조건부 연결 설정 데이터 준비
const conditionalSettings = isConditionalConnection()
? {
control: {
triggerType: "insert",
conditionTree:
conditions.length > 0
? {
type: "group" as const,
operator: "AND" as const,
children: conditions,
}
: null,
},
category: {
type: config.connectionType,
},
plan: {
sourceTable: fromTableName,
targetActions:
config.connectionType === "data-save"
? dataSaveSettings.actions.map((action) => ({
id: action.id,
actionType: action.actionType,
enabled: true,
conditions: action.conditions,
fieldMappings: action.fieldMappings.map((mapping) => ({
sourceTable: mapping.sourceTable,
sourceField: mapping.sourceField,
targetTable: mapping.targetTable,
targetField: mapping.targetField,
defaultValue: mapping.defaultValue,
transformFunction: mapping.transformFunction,
})),
splitConfig: action.splitConfig,
}))
: [],
},
}
: {};
// 메모리 기반 시스템: 관계 데이터만 생성하여 부모로 전달
const relationshipData: TableRelationship = {
relationship_name: config.relationshipName,
from_table_name: fromTableName,
to_table_name: toTableName,
from_column_name: selectedFromColumns.join(","), // 여러 컬럼을 콤마로 구분
to_column_name: selectedToColumns.join(","), // 여러 컬럼을 콤마로 구분
relationship_type: config.relationshipType,
connection_type: config.connectionType,
company_code: companyCode,
settings: {
...settings,
...conditionalSettings, // 조건부 연결 설정 추가
description: config.description,
multiColumnMapping: {
fromColumns: selectedFromColumns,
toColumns: selectedToColumns,
fromTable: fromTableName,
toTable: toTableName,
},
isMultiColumn: selectedFromColumns.length > 1 || selectedToColumns.length > 1,
columnCount: {
from: selectedFromColumns.length,
to: selectedToColumns.length,
},
},
};
toast.success("관계가 생성되었습니다!");
// 부모 컴포넌트로 관계 데이터 전달 (DB 저장 없이)
onConfirm(relationshipData);
handleCancel(); // 모달 닫기
};
const handleCancel = () => {
setConfig({
relationshipName: "",
relationshipType: "one-to-one",
connectionType: "simple-key",
fromColumnName: "",
toColumnName: "",
description: "",
});
onCancel();
};
if (!connection) return null;
// 선택된 컬럼 데이터 가져오기 (현재 사용되지 않음 - 향후 확장을 위해 유지)
// const selectedColumnsData = connection.selectedColumnsData || {};
// 조건부 연결인지 확인하는 헬퍼 함수
const isConditionalConnection = () => {
return config.connectionType === "data-save" || config.connectionType === "external-call";
};
// 조건 관리 헬퍼 함수들
const addCondition = () => {
const newCondition: ConditionNode = {
type: "condition",
field: "",
operator_type: "=",
value: "",
dataType: "string",
operator: "AND", // 기본값으로 AND 설정
};
setConditions([...conditions, newCondition]);
};
const updateCondition = (index: number, field: keyof ConditionNode, value: string) => {
const updatedConditions = [...conditions];
updatedConditions[index] = { ...updatedConditions[index], [field]: value };
setConditions(updatedConditions);
};
const removeCondition = (index: number) => {
const updatedConditions = conditions.filter((_, i) => i !== index);
setConditions(updatedConditions);
};
// 조건부 연결 설정 UI 렌더링
const renderConditionalSettings = () => {
return (
<div className="rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 p-4">
<div className="mb-4 flex items-center gap-2">
<Zap className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium"> ( )</span>
</div>
{/* 실행 조건 설정 */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm font-medium"> </Label>
<Button size="sm" variant="outline" onClick={() => addCondition()} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 조건 목록 */}
<div className="space-y-2">
{conditions.length === 0 ? (
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-gray-500">
.
<br />
.
</div>
) : (
conditions.map((condition, index) => (
<div key={index} className="flex items-center gap-2 rounded border bg-white p-2">
{/* 첫 번째 조건이 아닐 때 AND/OR 연산자를 앞에 표시 */}
{index > 0 && (
<Select
value={conditions[index - 1]?.operator || "AND"}
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "operator", value)}
>
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
<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="h-8 flex-1 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{fromTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.operator_type || "="}
onValueChange={(value) => updateCondition(index, "operator_type", value)}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
</SelectContent>
</Select>
{/* 데이터 타입에 따른 적절한 입력 컴포넌트 */}
{(() => {
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) {
return (
<Input
type="datetime-local"
value={condition.value || ""}
onChange={(e) => updateCondition(index, "value", e.target.value)}
className="h-8 flex-1 text-xs"
/>
);
} else if (dataType.includes("time")) {
return (
<Input
type="time"
value={condition.value || ""}
onChange={(e) => updateCondition(index, "value", e.target.value)}
className="h-8 flex-1 text-xs"
/>
);
} else if (dataType.includes("date")) {
return (
<Input
type="date"
value={condition.value || ""}
onChange={(e) => updateCondition(index, "value", e.target.value)}
className="h-8 flex-1 text-xs"
/>
);
} else if (
dataType.includes("int") ||
dataType.includes("numeric") ||
dataType.includes("decimal") ||
dataType.includes("float") ||
dataType.includes("double")
) {
return (
<Input
type="number"
placeholder="숫자"
value={condition.value || ""}
onChange={(e) => updateCondition(index, "value", e.target.value)}
className="h-8 flex-1 text-xs"
/>
);
} else if (dataType.includes("bool")) {
return (
<Select
value={condition.value || ""}
onValueChange={(value) => updateCondition(index, "value", value)}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">TRUE</SelectItem>
<SelectItem value="false">FALSE</SelectItem>
</SelectContent>
</Select>
);
} else {
return (
<Input
placeholder="값"
value={condition.value || ""}
onChange={(e) => updateCondition(index, "value", e.target.value)}
className="h-8 flex-1 text-xs"
/>
);
}
})()}
<Button size="sm" variant="ghost" onClick={() => removeCondition(index)} className="h-8 w-8 p-0">
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</div>
</div>
);
};
// 연결 종류별 설정 패널 렌더링
const renderConnectionTypeSettings = () => {
switch (config.connectionType) {
case "simple-key":
return (
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-blue-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Key className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="notes" className="text-sm">
</Label>
<Textarea
id="notes"
value={simpleKeySettings.notes}
onChange={(e) => setSimpleKeySettings({ ...simpleKeySettings, notes: e.target.value })}
placeholder="데이터 연결에 대한 설명을 입력하세요"
rows={2}
className="text-sm"
/>
</div>
</div>
</div>
);
case "data-save":
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={() => {
const newAction = {
id: `action_${dataSaveSettings.actions.length + 1}`,
name: `액션 ${dataSaveSettings.actions.length + 1}`,
actionType: "insert" as const,
fieldMappings: [],
conditions: [],
splitConfig: {
sourceField: "",
delimiter: ",",
targetField: "",
},
};
setDataSaveSettings({
...dataSaveSettings,
actions: [...dataSaveSettings.actions, newAction],
});
}}
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{dataSaveSettings.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">
{dataSaveSettings.actions.map((action, actionIndex) => (
<div key={action.id} className="rounded border bg-white p-3">
<div className="mb-3 flex items-center justify-between">
<Input
value={action.name}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].name = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-7 flex-1 text-xs font-medium"
placeholder="액션 이름"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newActions = dataSaveSettings.actions.filter((_, i) => i !== actionIndex);
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
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") => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].actionType = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<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>
{/* 액션별 개별 실행 조건 (선택사항) */}
<div className="mt-3">
<details className="group">
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
<div className="flex items-center gap-2">
🔍 ()
{action.conditions && action.conditions.length > 0 && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
{action.conditions.length}
</span>
)}
</div>
{action.conditions && action.conditions.length > 0 && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions = [];
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
title="조건 모두 삭제"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</summary>
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4">
<div className="mb-2 flex items-center justify-between">
<Button
size="sm"
variant="outline"
onClick={() => {
const newActions = [...dataSaveSettings.actions];
if (!newActions[actionIndex].conditions) {
newActions[actionIndex].conditions = [];
}
newActions[actionIndex].conditions = [
...(newActions[actionIndex].conditions || []),
{ field: "", operator: "=", value: "", logicalOperator: "AND" },
];
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 text-xs"
>
<Plus className="mr-1 h-2 w-2" />
</Button>
</div>
{action.conditions && action.conditions.length > 0 && (
<div className="space-y-2">
{action.conditions.map((condition, condIndex) => (
<div key={condIndex} className="flex items-center gap-2">
{/* 첫 번째 조건이 아닐 때 AND/OR 연산자를 앞에 표시 */}
{condIndex > 0 && (
<Select
value={action.conditions![condIndex - 1].logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex - 1].logicalOperator = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 w-20 border-green-200 bg-green-50 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
)}
{/* 조건 필드들 */}
<Select
value={condition.field}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].field = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 flex-1 text-xs">
<SelectValue placeholder="필드" />
</SelectTrigger>
<SelectContent>
{fromTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.operator}
onValueChange={(value: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE") => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].operator = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
</SelectContent>
</Select>
{/* 데이터 타입에 따른 적절한 입력 컴포넌트 */}
{(() => {
const selectedColumn = fromTableColumns.find(
(col) => col.columnName === condition.field,
);
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
if (
dataType.includes("timestamp") ||
dataType.includes("datetime") ||
dataType.includes("date")
) {
return (
<Input
type="datetime-local"
value={condition.value}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].value = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 flex-1 text-xs"
/>
);
} else if (dataType.includes("time")) {
return (
<Input
type="time"
value={condition.value}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].value = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 flex-1 text-xs"
/>
);
} else if (dataType.includes("date")) {
return (
<Input
type="date"
value={condition.value}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].value = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 flex-1 text-xs"
/>
);
} else if (
dataType.includes("int") ||
dataType.includes("numeric") ||
dataType.includes("decimal") ||
dataType.includes("float") ||
dataType.includes("double")
) {
return (
<Input
type="number"
placeholder="숫자"
value={condition.value}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].value = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 flex-1 text-xs"
/>
);
} else if (dataType.includes("bool")) {
return (
<Select
value={condition.value}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].value = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 flex-1 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">TRUE</SelectItem>
<SelectItem value="false">FALSE</SelectItem>
</SelectContent>
</Select>
);
} else {
return (
<Input
placeholder="값"
value={condition.value}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].value = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 flex-1 text-xs"
/>
);
}
})()}
<Button
size="sm"
variant="ghost"
onClick={() => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions = newActions[
actionIndex
].conditions!.filter((_, i) => i !== condIndex);
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 w-6 p-0"
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
))}
</div>
)}
</div>
</details>
</div>
{/* 데이터 분할 설정 (선택사항) */}
<div className="mt-3">
<details className="group">
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
<div className="flex items-center gap-2">
()
{action.splitConfig && action.splitConfig.sourceField && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
</span>
)}
</div>
{action.splitConfig && action.splitConfig.sourceField && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].splitConfig = {
sourceField: "",
delimiter: ",",
targetField: "",
};
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
title="분할 설정 초기화"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</summary>
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4">
<Label className="text-xs font-medium"> </Label>
<div className="mt-1 grid grid-cols-3 gap-2">
<div>
<Label className="text-xs text-gray-500"> </Label>
<Select
value={action.splitConfig?.sourceField || ""}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
if (!newActions[actionIndex].splitConfig) {
newActions[actionIndex].splitConfig = {
sourceField: "",
delimiter: ",",
targetField: "",
};
}
newActions[actionIndex].splitConfig!.sourceField = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{fromTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-500"></Label>
<Input
value={action.splitConfig?.delimiter || ","}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
if (!newActions[actionIndex].splitConfig) {
newActions[actionIndex].splitConfig = {
sourceField: "",
delimiter: ",",
targetField: "",
};
}
newActions[actionIndex].splitConfig!.delimiter = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 text-xs"
placeholder=","
/>
</div>
<div>
<Label className="text-xs text-gray-500"> </Label>
<Select
value={action.splitConfig?.targetField || ""}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
if (!newActions[actionIndex].splitConfig) {
newActions[actionIndex].splitConfig = {
sourceField: "",
delimiter: ",",
targetField: "",
};
}
newActions[actionIndex].splitConfig!.targetField = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{toTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</details>
</div>
{/* 필드 매핑 */}
<div className="mt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings.push({
sourceTable: "",
sourceField: "",
targetTable: "",
targetField: "",
defaultValue: "",
transformFunction: "",
});
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 text-xs"
>
<Plus className="mr-1 h-2 w-2" />
</Button>
</div>
<div className="space-y-3">
{action.fieldMappings.map((mapping, mappingIndex) => (
<div key={mappingIndex} className="rounded border bg-white p-2">
{/* 컴팩트한 매핑 표시 */}
<div className="flex items-center gap-2 text-xs">
{/* 소스 */}
<div className="flex items-center gap-1 rounded bg-blue-50 px-2 py-1">
<Select
value={mapping.sourceTable || ""}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = value;
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = "";
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
<SelectValue placeholder="테이블" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
<div className="truncate" title={table.tableName}>
{table.tableName}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-gray-400">.</span>
<Select
value={mapping.sourceField}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={!mapping.sourceTable}
>
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{mapping.sourceTable &&
tableColumnsCache[mapping.sourceTable]?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
<div className="truncate" title={column.columnName}>
{column.columnName}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-gray-400"></div>
{/* 타겟 */}
<div className="flex items-center gap-1 rounded bg-green-50 px-2 py-1">
<Select
value={mapping.targetTable || ""}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].targetTable = value;
newActions[actionIndex].fieldMappings[mappingIndex].targetField = "";
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
<SelectValue placeholder="테이블" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
<div className="truncate" title={table.tableName}>
{table.tableName}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-gray-400">.</span>
<Select
value={mapping.targetField}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].targetField = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={!mapping.targetTable}
>
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{mapping.targetTable &&
tableColumnsCache[mapping.targetTable]?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
<div className="truncate" title={column.columnName}>
{column.columnName}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 기본값 (인라인) */}
<Input
value={mapping.defaultValue || ""}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].defaultValue = e.target.value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 w-20 text-xs"
placeholder="기본값"
/>
{/* 삭제 버튼 */}
<Button
size="sm"
variant="ghost"
onClick={() => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings = newActions[
actionIndex
].fieldMappings.filter((_, i) => i !== mappingIndex);
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
case "external-call":
return (
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Globe className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="callType" className="text-sm">
</Label>
<Select
value={externalCallSettings.callType}
onValueChange={(value: "rest-api" | "email" | "webhook" | "ftp" | "queue") =>
setExternalCallSettings({ ...externalCallSettings, callType: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rest-api">REST API </SelectItem>
<SelectItem value="email"> </SelectItem>
<SelectItem value="webhook"></SelectItem>
<SelectItem value="ftp">FTP </SelectItem>
<SelectItem value="queue"> </SelectItem>
</SelectContent>
</Select>
</div>
{externalCallSettings.callType === "rest-api" && (
<>
<div>
<Label htmlFor="apiUrl" className="text-sm">
API URL
</Label>
<Input
id="apiUrl"
value={externalCallSettings.apiUrl}
onChange={(e) => setExternalCallSettings({ ...externalCallSettings, apiUrl: e.target.value })}
placeholder="https://api.example.com/webhook"
className="text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="httpMethod" className="text-sm">
HTTP Method
</Label>
<Select
value={externalCallSettings.httpMethod}
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
setExternalCallSettings({ ...externalCallSettings, httpMethod: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="headers" className="text-sm">
Headers
</Label>
<Textarea
id="headers"
value={externalCallSettings.headers}
onChange={(e) => setExternalCallSettings({ ...externalCallSettings, headers: e.target.value })}
placeholder="{}"
rows={1}
className="text-sm"
/>
</div>
</div>
<div>
<Label htmlFor="bodyTemplate" className="text-sm">
Body Template
</Label>
<Textarea
id="bodyTemplate"
value={externalCallSettings.bodyTemplate}
onChange={(e) =>
setExternalCallSettings({ ...externalCallSettings, bodyTemplate: e.target.value })
}
placeholder="{}"
rows={2}
className="text-sm"
/>
</div>
</>
)}
</div>
</div>
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={handleCancel}>
<DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Link className="h-4 w-4" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 테이블 및 컬럼 선택 */}
<div className="rounded-lg border bg-gray-50 p-4">
<div className="mb-4 text-sm font-medium"> </div>
{/* 현재 선택된 테이블 표시 */}
<div className="mb-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedFromTable})</span>
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedToTable})</span>
</div>
</div>
</div>
{/* 컬럼 선택 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{fromTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedFromColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedFromColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedFromColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{fromTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{toTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedToColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedToColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedToColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{toTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
</div>
</div>
{/* 선택된 컬럼 미리보기 */}
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600"> From </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedFromColumns.length > 0 ? (
selectedFromColumns.map((column) => (
<Badge key={column} variant="outline" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600"> To </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedToColumns.length > 0 ? (
selectedToColumns.map((column) => (
<Badge key={column} variant="secondary" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
</div>
)}
</div>
{/* 기본 연결 설정 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="relationshipName"> </Label>
<Input
id="relationshipName"
value={config.relationshipName}
onChange={(e) => setConfig({ ...config, relationshipName: e.target.value })}
placeholder="employee_id_department_id_연결"
className="text-sm"
/>
</div>
</div>
{/* 연결 종류 선택 */}
<div>
<Label className="text-sm font-medium"> </Label>
<div className="mt-2 grid grid-cols-3 gap-2">
<div
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
config.connectionType === "simple-key"
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => setConfig({ ...config, connectionType: "simple-key" })}
>
<Key className="mx-auto h-6 w-6 text-blue-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600"> </div>
</div>
<div
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
config.connectionType === "data-save"
? "border-green-500 bg-green-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => setConfig({ ...config, connectionType: "data-save" })}
>
<Save className="mx-auto h-6 w-6 text-green-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600"> </div>
</div>
<div
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
config.connectionType === "external-call"
? "border-orange-500 bg-orange-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => setConfig({ ...config, connectionType: "external-call" })}
>
<Globe className="mx-auto h-6 w-6 text-orange-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600">API/ </div>
</div>
</div>
</div>
{/* 조건부 연결을 위한 조건 설정 */}
{isConditionalConnection() && renderConditionalSettings()}
{/* 연결 종류별 상세 설정 */}
{renderConnectionTypeSettings()}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm} disabled={!config.relationshipName}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};