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

2177 lines
98 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;
const connectionType =
(existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key";
setConfig({
relationshipName: existingRel?.relationshipName || `${fromDisplayName}${toDisplayName}`,
connectionType,
fromColumnName: "",
toColumnName: "",
settings: existingRel?.settings || {},
});
// 🔥 기존 설정 데이터 로드
if (existingRel?.settings) {
const settings = existingRel.settings;
if (connectionType === "simple-key" && settings.notes) {
setSimpleKeySettings({
notes: settings.notes as string,
});
} else if (connectionType === "data-save" && settings.actions) {
// data-save 설정 로드 - 안전하게 처리
const actionsData = Array.isArray(settings.actions) ? settings.actions : [];
setDataSaveSettings({
actions: actionsData.map((action: Record<string, unknown>) => ({
id: (action.id as string) || `action-${Date.now()}`,
name: (action.name as string) || "새 액션",
actionType: (action.actionType as "insert" | "update" | "delete" | "upsert") || "insert",
conditions: Array.isArray(action.conditions)
? action.conditions.map(
(condition: Record<string, unknown>) =>
({
...condition,
operator: condition.operator || "=", // 기본값 보장
}) as ConditionNode,
)
: [],
fieldMappings: Array.isArray(action.fieldMappings)
? action.fieldMappings.map((mapping: Record<string, unknown>) => ({
sourceTable: (mapping.sourceTable as string) || "",
sourceField: (mapping.sourceField as string) || "",
targetTable: (mapping.targetTable as string) || "",
targetField: (mapping.targetField as string) || "",
defaultValue: (mapping.defaultValue as string) || "",
transformFunction: (mapping.transformFunction as string) || "",
}))
: [],
splitConfig: action.splitConfig
? {
sourceField: ((action.splitConfig as Record<string, unknown>).sourceField as string) || "",
delimiter: ((action.splitConfig as Record<string, unknown>).delimiter as string) || ",",
targetField: ((action.splitConfig as Record<string, unknown>).targetField as string) || "",
}
: undefined,
})),
});
// 전체 실행 조건 로드 - 안전하게 처리
if (settings.control) {
const controlSettings = settings.control as { conditionTree?: ConditionNode[] };
if (Array.isArray(controlSettings.conditionTree)) {
// 기존 조건이 없을 때만 로드 (사용자가 추가한 조건 보존)
setConditions((prevConditions) => {
if (prevConditions.length === 0) {
return controlSettings.conditionTree || [];
}
return prevConditions;
});
} else {
// 기존 조건이 없을 때만 초기화
setConditions((prevConditions) => (prevConditions.length === 0 ? [] : prevConditions));
}
} else {
// 기존 조건이 없을 때만 초기화
setConditions((prevConditions) => (prevConditions.length === 0 ? [] : prevConditions));
}
} else if (connectionType === "external-call") {
setExternalCallSettings({
callType: (settings.callType as "rest-api" | "webhook") || "rest-api",
apiUrl: (settings.apiUrl as string) || "",
httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST",
headers: (settings.headers as string) || "{}",
bodyTemplate: (settings.bodyTemplate as string) || "{}",
});
}
} else {
// 기본값 설정
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?.map((condition) => {
// 모든 조건 타입에 대해 operator 필드 보장
const baseCondition = { ...condition };
if (condition.type === "condition") {
baseCondition.operator = condition.operator || "=";
}
return baseCondition;
}) || [],
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" as const,
field: "",
operator: "=",
value: "",
dataType: "string",
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
...(conditions.length > 0 &&
conditions[conditions.length - 1]?.type !== "group-start" && { logicalOperator: "AND" as const }),
};
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" as const,
groupId,
groupLevel,
// 첫 번째 그룹이 아니면 logicalOperator 추가
...(conditions.length > 0 && { logicalOperator: "AND" as const }),
};
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" as const,
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" as const,
groupId,
groupLevel,
// 첫 번째 그룹이 아니면 logicalOperator 추가
...(currentConditions.length > 0 && { logicalOperator: "AND" as const }),
};
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" as const,
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 || "="}
onValueChange={(value: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE") => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].conditions![condIndex].operator = 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>
) : (
<React.Fragment key="conditions-list">
{conditions.map((condition, index) => {
// 그룹 시작 렌더링
if (condition.type === "group-start") {
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 그룹 시작 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */}
{index > 0 && conditions[index - 1]?.type !== "group-end" && (
<Select
value={condition.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => updateCondition(index, "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>
{/* 그룹 끝 다음에 다른 조건이나 그룹이 있으면 논리 연산자 표시 */}
{index < conditions.length - 1 && (
<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>
);
}
// 일반 조건 렌더링
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 일반 조건 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */}
{index > 0 &&
conditions[index - 1]?.type !== "group-start" &&
conditions[index - 1]?.type !== "group-end" && (
<Select
value={condition.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => updateCondition(index, "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 || "="}
onValueChange={(value) => updateCondition(index, "operator", 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>
);
})}
</React.Fragment>
)}
</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 = [];
}
const currentConditions = newActions[actionIndex].conditions || [];
const newCondition: ConditionNode = {
id: generateId(),
type: "condition" as const,
field: "",
operator: "=",
value: "",
dataType: "string",
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
...(currentConditions.length > 0 &&
currentConditions[currentConditions.length - 1]?.type !== "group-start" && {
logicalOperator: "AND" as const,
}),
};
newActions[actionIndex].conditions = [...currentConditions, newCondition];
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) => (
<div key={`action-${actionIndex}-condition-${condition.id}`}>
{renderActionCondition(condition, condIndex, actionIndex)}
</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={`${action.id}-mapping-${mappingIndex}-${mapping.sourceField || "empty"}-${mapping.targetField || "empty"}`}
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>
);
};