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

2052 lines
90 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.

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

"use client";
import React, { useState, useEffect } from "react";
import { 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;
connectionType: string;
settings?: Record<string, unknown>;
};
}
// 연결 설정 타입
interface ConnectionConfig {
relationshipName: string;
connectionType: "simple-key" | "data-save" | "external-call";
fromColumnName: string;
toColumnName: string;
settings?: Record<string, unknown>;
}
// 단순 키값 연결 설정
interface SimpleKeySettings {
notes: string;
}
// 데이터 저장 설정
interface DataSaveSettings {
actions: Array<{
id: string;
name: string;
actionType: "insert" | "update" | "delete" | "upsert";
conditions?: ConditionNode[];
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: "",
connectionType: "simple-key",
fromColumnName: "",
toColumnName: "",
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}`,
connectionType: (existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key",
fromColumnName: "",
toColumnName: "",
settings: existingRel?.settings || {},
});
// 단순 키값 연결 기본값 설정
setSimpleKeySettings({
notes: `${fromDisplayName}${toDisplayName} 간의 키값 연결`,
});
// 데이터 저장 기본값 설정 (빈 배열로 시작)
setDataSaveSettings({
actions: [],
});
// 🔥 필드 선택 상태 초기화
setSelectedFromColumns([]);
setSelectedToColumns([]);
// 외부 호출 기본값 설정
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 (config.connectionType === "simple-key") {
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 ? 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 finalFromColumns = config.connectionType === "simple-key" ? selectedFromColumns : [];
const finalToColumns = config.connectionType === "simple-key" ? selectedToColumns : [];
// 메모리 기반 시스템: 관계 데이터만 생성하여 부모로 전달
const relationshipData: TableRelationship = {
relationship_name: config.relationshipName,
from_table_name: fromTableName,
to_table_name: toTableName,
from_column_name: finalFromColumns.join(","), // 여러 컬럼을 콤마로 구분
to_column_name: finalToColumns.join(","), // 여러 컬럼을 콤마로 구분
connection_type: config.connectionType,
company_code: companyCode,
settings: {
...settings,
...conditionalSettings, // 조건부 연결 설정 추가
// 중복 제거: multiColumnMapping, isMultiColumn, columnCount, description 제거
// 필요시 from_column_name, to_column_name에서 split으로 추출 가능
},
};
toast.success("관계가 생성되었습니다!");
// 부모 컴포넌트로 관계 데이터 전달 (DB 저장 없이)
onConfirm(relationshipData);
handleCancel(); // 모달 닫기
};
const handleCancel = () => {
setConfig({
relationshipName: "",
connectionType: "simple-key",
fromColumnName: "",
toColumnName: "",
});
onCancel();
};
if (!connection) return null;
// 선택된 컬럼 데이터 가져오기 (현재 사용되지 않음 - 향후 확장을 위해 유지)
// const selectedColumnsData = connection.selectedColumnsData || {};
// 조건부 연결인지 확인하는 헬퍼 함수
const isConditionalConnection = () => {
return config.connectionType === "data-save" || config.connectionType === "external-call";
};
// 고유 ID 생성 헬퍼
const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 조건 관리 헬퍼 함수들
const addCondition = () => {
const newCondition: ConditionNode = {
id: generateId(),
type: "condition",
field: "",
operator_type: "=",
value: "",
dataType: "string",
logicalOperator: "AND", // 기본값으로 AND 설정
};
setConditions([...conditions, newCondition]);
};
// 그룹 시작 추가
const addGroupStart = () => {
const groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const groupLevel = getNextGroupLevel();
const groupStart: ConditionNode = {
id: generateId(),
type: "group-start",
groupId,
groupLevel,
logicalOperator: conditions.length > 0 ? "AND" : undefined,
};
setConditions([...conditions, groupStart]);
};
// 그룹 끝 추가
const addGroupEnd = () => {
// 가장 최근에 열린 그룹 찾기
const openGroups = findOpenGroups();
if (openGroups.length === 0) {
toast.error("닫을 그룹이 없습니다.");
return;
}
const lastOpenGroup = openGroups[openGroups.length - 1];
const groupEnd: ConditionNode = {
id: generateId(),
type: "group-end",
groupId: lastOpenGroup.groupId,
groupLevel: lastOpenGroup.groupLevel,
};
setConditions([...conditions, groupEnd]);
};
// 다음 그룹 레벨 계산
const getNextGroupLevel = (): number => {
const openGroups = findOpenGroups();
return openGroups.length;
};
// 열린 그룹 찾기
const findOpenGroups = () => {
const openGroups: Array<{ groupId: string; groupLevel: number }> = [];
for (const condition of conditions) {
if (condition.type === "group-start") {
openGroups.push({
groupId: condition.groupId!,
groupLevel: condition.groupLevel!,
});
} else if (condition.type === "group-end") {
// 해당 그룹 제거
const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId);
if (groupIndex !== -1) {
openGroups.splice(groupIndex, 1);
}
}
}
return openGroups;
};
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 conditionToRemove = conditions[index];
// 그룹 시작/끝을 삭제하는 경우 해당 그룹 전체 삭제
if (conditionToRemove.type === "group-start" || conditionToRemove.type === "group-end") {
removeGroup(conditionToRemove.groupId!);
} else {
const updatedConditions = conditions.filter((_, i) => i !== index);
setConditions(updatedConditions);
}
};
// 그룹 전체 삭제
const removeGroup = (groupId: string) => {
const updatedConditions = conditions.filter((c) => c.groupId !== groupId);
setConditions(updatedConditions);
};
// 현재 조건의 그룹 레벨 계산
const getCurrentGroupLevel = (conditionIndex: number): number => {
let level = 0;
for (let i = 0; i < conditionIndex; i++) {
const condition = conditions[i];
if (condition.type === "group-start") {
level++;
} else if (condition.type === "group-end") {
level--;
}
}
return level;
};
// 액션별 조건 그룹 관리 함수들
const addActionGroupStart = (actionIndex: number) => {
const groupId = `action_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
const groupLevel = getActionNextGroupLevel(currentConditions);
const groupStart: ConditionNode = {
id: generateId(),
type: "group-start",
groupId,
groupLevel,
logicalOperator: currentConditions.length > 0 ? "AND" : undefined,
};
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions = [...currentConditions, groupStart];
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
};
const addActionGroupEnd = (actionIndex: number) => {
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
const openGroups = findActionOpenGroups(currentConditions);
if (openGroups.length === 0) {
toast.error("닫을 그룹이 없습니다.");
return;
}
const lastOpenGroup = openGroups[openGroups.length - 1];
const groupEnd: ConditionNode = {
id: generateId(),
type: "group-end",
groupId: lastOpenGroup.groupId,
groupLevel: lastOpenGroup.groupLevel,
};
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions = [...currentConditions, groupEnd];
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
};
// 액션별 다음 그룹 레벨 계산
const getActionNextGroupLevel = (conditions: ConditionNode[]): number => {
const openGroups = findActionOpenGroups(conditions);
return openGroups.length;
};
// 액션별 열린 그룹 찾기
const findActionOpenGroups = (conditions: ConditionNode[]) => {
const openGroups: Array<{ groupId: string; groupLevel: number }> = [];
for (const condition of conditions) {
if (condition.type === "group-start") {
openGroups.push({
groupId: condition.groupId!,
groupLevel: condition.groupLevel!,
});
} else if (condition.type === "group-end") {
const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId);
if (groupIndex !== -1) {
openGroups.splice(groupIndex, 1);
}
}
}
return openGroups;
};
// 액션별 현재 조건의 그룹 레벨 계산
const getActionCurrentGroupLevel = (conditions: ConditionNode[], conditionIndex: number): number => {
let level = 0;
for (let i = 0; i < conditionIndex; i++) {
const condition = conditions[i];
if (condition.type === "group-start") {
level++;
} else if (condition.type === "group-end") {
level--;
}
}
return level;
};
// 액션별 조건 렌더링 함수
const renderActionCondition = (condition: ConditionNode, condIndex: number, actionIndex: number) => {
// 그룹 시작 렌더링
if (condition.type === "group-start") {
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 그룹 시작 앞의 논리 연산자 */}
{condIndex > 0 && (
<Select
value={dataSaveSettings.actions[actionIndex].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-24 border-green-200 bg-green-50 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
)}
<div
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
>
<span className="font-mono text-xs text-green-600">(</span>
<span className="text-xs text-green-600"> </span>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter(
(c) => c.groupId !== condition.groupId,
);
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-4 w-4 p-0"
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
</div>
);
}
// 그룹 끝 렌더링
if (condition.type === "group-end") {
return (
<div key={condition.id} className="flex items-center gap-2">
<div
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
>
<span className="font-mono text-xs text-green-600">)</span>
<span className="text-xs text-green-600"> </span>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter(
(c) => c.groupId !== condition.groupId,
);
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-4 w-4 p-0"
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
</div>
);
}
// 일반 조건 렌더링 (기존 로직 간소화)
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */}
{condIndex > 0 && dataSaveSettings.actions[actionIndex].conditions![condIndex - 1]?.type !== "group-start" && (
<Select
value={dataSaveSettings.actions[actionIndex].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-24 border-green-200 bg-green-50 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
)}
<div
className="flex flex-1 items-center gap-2 rounded border bg-white p-1"
style={{
marginLeft: `${getActionCurrentGroupLevel(dataSaveSettings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
}}
>
<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_type || "="}
onValueChange={(value: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE") => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].operator_type = value;
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
>
<SelectTrigger className="h-6 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={String(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={String(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={String(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={String(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={String(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={String(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>
);
};
// 조건부 연결 설정 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>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => addCondition()} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" variant="outline" onClick={() => addGroupStart()} className="h-7 text-xs">
(
</Button>
<Button size="sm" variant="outline" onClick={() => addGroupEnd()} className="h-7 text-xs">
)
</Button>
</div>
</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) => {
// 그룹 시작 렌더링
if (condition.type === "group-start") {
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 그룹 시작 앞의 논리 연산자 */}
{index > 0 && (
<Select
value={conditions[index - 1]?.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "logicalOperator", 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>
)}
{/* 그룹 레벨에 따른 들여쓰기 */}
<div
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
>
<span className="font-mono text-sm text-blue-600">(</span>
<span className="text-xs text-blue-600"> </span>
<Button
size="sm"
variant="ghost"
onClick={() => removeCondition(index)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
}
// 그룹 끝 렌더링
if (condition.type === "group-end") {
return (
<div key={condition.id} className="flex items-center gap-2">
<div
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
>
<span className="font-mono text-sm text-blue-600">)</span>
<span className="text-xs text-blue-600"> </span>
<Button
size="sm"
variant="ghost"
onClick={() => removeCondition(index)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
}
// 일반 조건 렌더링
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */}
{index > 0 && conditions[index - 1]?.type !== "group-start" && (
<Select
value={conditions[index - 1]?.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "logicalOperator", 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>
)}
{/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */}
<div
className="flex flex-1 items-center gap-2 rounded border bg-white p-2"
style={{ marginLeft: `${getCurrentGroupLevel(index) * 20}px` }}
>
{/* 조건 필드 선택 */}
<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={String(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={String(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={String(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={String(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={String(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={String(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>
</div>
);
};
// 연결 종류별 설정 패널 렌더링
const renderConnectionTypeSettings = () => {
switch (config.connectionType) {
case "simple-key":
return (
<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="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>
</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">
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => {
const newActions = [...dataSaveSettings.actions];
if (!newActions[actionIndex].conditions) {
newActions[actionIndex].conditions = [];
}
newActions[actionIndex].conditions = [
...(newActions[actionIndex].conditions || []),
{
id: generateId(),
type: "condition",
field: "",
operator_type: "=",
value: "",
dataType: "string",
logicalOperator: "AND",
},
];
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="h-6 text-xs"
>
<Plus className="mr-1 h-2 w-2" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => addActionGroupStart(actionIndex)}
className="h-6 text-xs"
>
(
</Button>
<Button
size="sm"
variant="outline"
onClick={() => addActionGroupEnd(actionIndex)}
className="h-6 text-xs"
>
)
</Button>
</div>
</div>
{action.conditions && action.conditions.length > 0 && (
<div className="space-y-2">
{action.conditions.map((condition, condIndex) =>
renderActionCondition(condition, condIndex, actionIndex),
)}
</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 || "__EMPTY__"}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
// 🔥 "__EMPTY__" 값을 빈 문자열로 변환
const actualValue = value === "__EMPTY__" ? "" : value;
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = actualValue;
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = "";
// 🔥 소스 선택 시 기본값 초기화 (빈 값이 아닐 때만)
if (actualValue) {
newActions[actionIndex].fieldMappings[mappingIndex].defaultValue = "";
}
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={!!(mapping.defaultValue && mapping.defaultValue.trim())}
>
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
<SelectValue placeholder="테이블" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__EMPTY__"> ( )</SelectItem>
{availableTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
<div className="truncate" title={table.tableName}>
{table.tableName}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 🔥 FROM 테이블 클리어 버튼 */}
{mapping.sourceTable && (
<button
onClick={() => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = "";
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = "";
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="소스 테이블 지우기"
>
×
</button>
)}
<span className="text-gray-400">.</span>
<Select
value={mapping.sourceField}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = value;
// 🔥 소스 필드 선택 시 기본값 초기화
if (value) {
newActions[actionIndex].fieldMappings[mappingIndex].defaultValue = "";
}
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={
!mapping.sourceTable || !!(mapping.defaultValue && mapping.defaultValue.trim())
}
>
<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;
// 🔥 기본값 입력 시 소스 테이블/필드 초기화
if (e.target.value.trim()) {
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = "";
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = "";
}
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={!!mapping.sourceTable}
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="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={(() => {
const hasRelationshipName = !!config.relationshipName;
const isDataSave = config.connectionType === "data-save";
const hasActions = dataSaveSettings.actions.length > 0;
const allActionsHaveMappings = dataSaveSettings.actions.every(
(action) => action.fieldMappings.length > 0,
);
const allMappingsComplete = dataSaveSettings.actions.every((action) =>
action.fieldMappings.every((mapping) => {
// 타겟은 항상 필요
if (!mapping.targetTable || !mapping.targetField) return false;
// 소스와 기본값 중 하나는 있어야 함
const hasSource = mapping.sourceTable && mapping.sourceField;
const hasDefault = mapping.defaultValue && mapping.defaultValue.trim();
// FROM 테이블이 비어있으면 기본값이 필요
if (!mapping.sourceTable) {
return !!hasDefault;
}
// FROM 테이블이 있으면 소스 매핑 완성 또는 기본값 필요
return hasSource || hasDefault;
}),
);
console.log("🔍 버튼 비활성화 디버깅:", {
hasRelationshipName,
isDataSave,
hasActions,
allActionsHaveMappings,
allMappingsComplete,
dataSaveSettings,
config,
});
const shouldDisable =
!hasRelationshipName ||
(isDataSave && (!hasActions || !allActionsHaveMappings || !allMappingsComplete));
console.log("🔍 최종 비활성화 여부:", shouldDisable);
return shouldDisable;
})()}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};