1629 lines
77 KiB
TypeScript
1629 lines
77 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* INSERT 액션 노드 속성 편집 (개선 버전)
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { cn } from "@/lib/utils";
|
||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||
import { tableTypeApi } from "@/lib/api/screen";
|
||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||
import { getNumberingRules } from "@/lib/api/numberingRule";
|
||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||
|
||
interface InsertActionPropertiesProps {
|
||
nodeId: string;
|
||
data: InsertActionNodeData;
|
||
}
|
||
|
||
interface TableOption {
|
||
tableName: string;
|
||
displayName: string;
|
||
description: string;
|
||
label: string;
|
||
}
|
||
|
||
interface ColumnInfo {
|
||
columnName: string;
|
||
columnLabel?: string;
|
||
dataType: string;
|
||
isNullable: boolean;
|
||
columnDefault?: string | null;
|
||
}
|
||
|
||
export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) {
|
||
const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore();
|
||
|
||
// 🔥 타겟 타입 상태
|
||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||
|
||
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
||
|
||
// 내부 DB 테이블 관련 상태
|
||
const [tables, setTables] = useState<TableOption[]>([]);
|
||
const [tablesLoading, setTablesLoading] = useState(false);
|
||
const [tablesOpen, setTablesOpen] = useState(false);
|
||
|
||
// 컬럼 관련 상태
|
||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||
|
||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||
// REST API 소스 노드 연결 여부
|
||
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||
|
||
// Combobox 열림 상태 관리 (필드 매핑)
|
||
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
|
||
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
|
||
|
||
// 🔥 외부 DB 관련 상태
|
||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
|
||
data.externalConnectionId,
|
||
);
|
||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||
|
||
// 🔥 REST API 관련 상태
|
||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||
const [apiMethod, setApiMethod] = useState<"POST" | "PUT" | "PATCH">(data.apiMethod || "POST");
|
||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||
|
||
// 🔥 채번 규칙 관련 상태
|
||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
||
const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState<boolean[]>([]);
|
||
|
||
// 데이터 변경 시 로컬 상태 업데이트
|
||
useEffect(() => {
|
||
setDisplayName(data.displayName || data.targetTable);
|
||
setTargetTable(data.targetTable);
|
||
setFieldMappings(data.fieldMappings || []);
|
||
}, [data]);
|
||
|
||
// 내부 DB 테이블 목록 로딩
|
||
useEffect(() => {
|
||
if (targetType === "internal") {
|
||
loadTables();
|
||
}
|
||
}, [targetType]);
|
||
|
||
// 타겟 테이블 변경 시 컬럼 로딩 (내부 DB)
|
||
useEffect(() => {
|
||
if (targetType === "internal" && targetTable) {
|
||
loadColumns(targetTable);
|
||
}
|
||
}, [targetType, targetTable]);
|
||
|
||
// 🔥 외부 커넥션 로드 (캐시 우선)
|
||
useEffect(() => {
|
||
if (targetType === "external") {
|
||
loadExternalConnections();
|
||
}
|
||
}, [targetType]);
|
||
|
||
// 🔥 외부 커넥션 변경 시 테이블 로드
|
||
useEffect(() => {
|
||
if (targetType === "external" && selectedExternalConnectionId) {
|
||
loadExternalTables(selectedExternalConnectionId);
|
||
}
|
||
}, [targetType, selectedExternalConnectionId]);
|
||
|
||
// fieldMappings 변경 시 Combobox 열림 상태 초기화
|
||
useEffect(() => {
|
||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||
setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false));
|
||
}, [fieldMappings.length]);
|
||
|
||
// 🔥 채번 규칙 로딩 (자동 생성 사용 시)
|
||
useEffect(() => {
|
||
const loadNumberingRules = async () => {
|
||
setNumberingRulesLoading(true);
|
||
try {
|
||
const response = await getNumberingRules();
|
||
if (response.success && response.data) {
|
||
setNumberingRules(response.data);
|
||
console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`);
|
||
} else {
|
||
console.error("❌ 채번 규칙 로딩 실패:", response.error);
|
||
setNumberingRules([]);
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ 채번 규칙 로딩 오류:", error);
|
||
setNumberingRules([]);
|
||
} finally {
|
||
setNumberingRulesLoading(false);
|
||
}
|
||
};
|
||
|
||
loadNumberingRules();
|
||
}, []);
|
||
|
||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||
useEffect(() => {
|
||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
|
||
}
|
||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||
|
||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||
useEffect(() => {
|
||
const getAllSourceFields = (
|
||
targetNodeId: string,
|
||
visitedNodes = new Set<string>(),
|
||
sourcePath: string[] = [], // 🔥 소스 경로 추적
|
||
): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => {
|
||
if (visitedNodes.has(targetNodeId)) {
|
||
console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`);
|
||
return { fields: [], hasRestAPI: false };
|
||
}
|
||
visitedNodes.add(targetNodeId);
|
||
|
||
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
|
||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||
|
||
// 🔥 다중 소스 감지
|
||
if (sourceNodes.length > 1) {
|
||
console.log(`⚠️ 다중 소스 감지: ${sourceNodes.length}개 노드 연결됨`);
|
||
console.log(" 소스 노드들:", sourceNodes.map((n) => `${n.id}(${n.type})`).join(", "));
|
||
}
|
||
|
||
const fields: Array<{ name: string; label?: string; sourcePath?: string[] }> = [];
|
||
let foundRestAPI = false;
|
||
|
||
sourceNodes.forEach((node) => {
|
||
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
|
||
|
||
// 🔥 현재 노드를 경로에 추가
|
||
const currentPath = [...sourcePath, `${node.id}(${node.type})`];
|
||
|
||
// 1️⃣ 데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
|
||
if (node.type === "dataTransform") {
|
||
console.log("✅ 데이터 변환 노드 발견");
|
||
|
||
// 상위 노드의 원본 필드 먼저 수집
|
||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||
const upperFields = upperResult.fields;
|
||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||
console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`);
|
||
|
||
// 변환된 필드 추가 (in-place 변환 고려)
|
||
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
||
console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`);
|
||
const inPlaceFields = new Set<string>();
|
||
|
||
(node.data as any).transformations.forEach((transform: any) => {
|
||
const targetField = transform.targetField || transform.sourceField;
|
||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||
|
||
console.log(` 🔹 변환: ${transform.sourceField} → ${targetField} ${isInPlace ? "(in-place)" : ""}`);
|
||
|
||
if (isInPlace) {
|
||
inPlaceFields.add(transform.sourceField);
|
||
} else if (targetField) {
|
||
fields.push({
|
||
name: targetField,
|
||
label: transform.targetFieldLabel || targetField,
|
||
sourcePath: currentPath,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 상위 필드 추가
|
||
upperFields.forEach((field) => {
|
||
if (!inPlaceFields.has(field.name)) {
|
||
fields.push(field);
|
||
} else {
|
||
fields.push(field);
|
||
}
|
||
});
|
||
} else {
|
||
fields.push(...upperFields);
|
||
}
|
||
}
|
||
// 2️⃣ REST API 소스 노드
|
||
else if (node.type === "restAPISource") {
|
||
console.log("✅ REST API 소스 노드 발견");
|
||
foundRestAPI = true;
|
||
const responseFields = (node.data as any).responseFields;
|
||
|
||
if (responseFields && Array.isArray(responseFields)) {
|
||
console.log(`✅ REST API 노드에서 ${responseFields.length}개 필드 발견`);
|
||
responseFields.forEach((field: any) => {
|
||
const fieldName = field.name || field.fieldName;
|
||
const fieldLabel = field.label || field.displayName;
|
||
if (fieldName) {
|
||
fields.push({
|
||
name: fieldName,
|
||
label: fieldLabel,
|
||
sourcePath: currentPath,
|
||
});
|
||
}
|
||
});
|
||
} else {
|
||
console.log("⚠️ REST API 노드에 responseFields 없음");
|
||
}
|
||
}
|
||
// 3️⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
|
||
else if (node.type === "formulaTransform") {
|
||
console.log("✅ 수식 변환 노드 발견");
|
||
|
||
// 상위 노드의 필드 가져오기
|
||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||
fields.push(...upperResult.fields);
|
||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||
|
||
// 수식 변환 출력 필드 추가
|
||
const nodeData = node.data as any;
|
||
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
|
||
console.log(` 📊 ${nodeData.transformations.length}개 수식 변환 발견`);
|
||
nodeData.transformations.forEach((trans: any) => {
|
||
if (trans.outputField) {
|
||
fields.push({
|
||
name: trans.outputField,
|
||
label: trans.outputFieldLabel || trans.outputField,
|
||
sourcePath: currentPath,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
// 4️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||
else if (node.type === "aggregate") {
|
||
console.log("✅ 집계 노드 발견");
|
||
const nodeData = node.data as any;
|
||
|
||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||
console.log(` 📊 ${nodeData.groupByFields.length}개 그룹 필드 발견`);
|
||
nodeData.groupByFields.forEach((groupField: any) => {
|
||
const fieldName = groupField.field || groupField.fieldName;
|
||
if (fieldName) {
|
||
fields.push({
|
||
name: fieldName,
|
||
label: groupField.fieldLabel || fieldName,
|
||
sourcePath: currentPath,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||
if (Array.isArray(aggregations)) {
|
||
console.log(` 📊 ${aggregations.length}개 집계 함수 발견`);
|
||
aggregations.forEach((aggFunc: any) => {
|
||
// outputField 또는 targetField 둘 다 지원
|
||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||
// function 또는 aggregateType 둘 다 지원
|
||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||
if (outputFieldName) {
|
||
fields.push({
|
||
name: outputFieldName,
|
||
label:
|
||
aggFunc.outputFieldLabel ||
|
||
aggFunc.targetFieldLabel ||
|
||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||
sourcePath: currentPath,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||
}
|
||
// 4️⃣ 테이블/외부DB 소스 노드
|
||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||
const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id;
|
||
|
||
if (nodeFields && Array.isArray(nodeFields)) {
|
||
console.log(`✅ ${node.type}[${displayName}] 노드에서 ${nodeFields.length}개 필드 발견`);
|
||
nodeFields.forEach((field: any) => {
|
||
const fieldName = field.name || field.fieldName || field.column_name;
|
||
const fieldLabel = field.label || field.displayName || field.label_ko;
|
||
if (fieldName) {
|
||
// 🔥 다중 소스인 경우 필드명에 소스 표시
|
||
const displayLabel =
|
||
sourceNodes.length > 1 ? `${fieldLabel || fieldName} [${displayName}]` : fieldLabel || fieldName;
|
||
|
||
fields.push({
|
||
name: fieldName,
|
||
label: displayLabel,
|
||
sourcePath: currentPath,
|
||
});
|
||
}
|
||
});
|
||
} else {
|
||
console.log(`⚠️ ${node.type} 노드에 필드 정의 없음 → 상위 노드 탐색`);
|
||
// 필드가 없으면 상위 노드로 계속 탐색
|
||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||
fields.push(...upperResult.fields);
|
||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||
}
|
||
}
|
||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||
else {
|
||
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
|
||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||
fields.push(...upperResult.fields);
|
||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||
console.log(` 📤 상위 노드에서 ${upperResult.fields.length}개 필드 가져옴`);
|
||
}
|
||
});
|
||
|
||
return { fields, hasRestAPI: foundRestAPI };
|
||
};
|
||
|
||
console.log("🔍 INSERT 노드 ID:", nodeId);
|
||
const result = getAllSourceFields(nodeId);
|
||
|
||
console.log("📊 필드 수집 완료:");
|
||
console.log(` - 총 필드 수: ${result.fields.length}개`);
|
||
console.log(` - REST API 포함: ${result.hasRestAPI}`);
|
||
|
||
// 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시
|
||
const fieldMap = new Map<string, (typeof result.fields)[number]>();
|
||
const duplicateFields = new Set<string>();
|
||
|
||
result.fields.forEach((field) => {
|
||
const key = `${field.name}`;
|
||
if (fieldMap.has(key)) {
|
||
duplicateFields.add(field.name);
|
||
}
|
||
// 중복이면 마지막 값으로 덮어씀 (기존 동작 유지)
|
||
fieldMap.set(key, field);
|
||
});
|
||
|
||
if (duplicateFields.size > 0) {
|
||
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
|
||
console.warn(" → 마지막으로 발견된 필드만 표시됩니다.");
|
||
console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!");
|
||
}
|
||
|
||
const uniqueFields = Array.from(fieldMap.values());
|
||
|
||
setSourceFields(uniqueFields);
|
||
setHasRestAPISource(result.hasRestAPI);
|
||
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
|
||
console.log("✅ REST API 소스 연결:", result.hasRestAPI);
|
||
}, [nodeId, nodes, edges]);
|
||
|
||
/**
|
||
* 테이블 목록 로드
|
||
*/
|
||
const loadTables = async () => {
|
||
try {
|
||
setTablesLoading(true);
|
||
const tableList = await tableTypeApi.getTables();
|
||
|
||
const options: TableOption[] = tableList.map((table) => {
|
||
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
|
||
|
||
return {
|
||
tableName: table.tableName,
|
||
displayName: table.displayName || table.tableName,
|
||
description: table.description || "",
|
||
label,
|
||
};
|
||
});
|
||
|
||
setTables(options);
|
||
console.log(`✅ 테이블 ${options.length}개 로딩 완료`);
|
||
} catch (error) {
|
||
console.error("❌ 테이블 목록 로딩 실패:", error);
|
||
setTables([]);
|
||
} finally {
|
||
setTablesLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 타겟 테이블의 컬럼 목록 로드
|
||
*/
|
||
const loadColumns = async (tableName: string) => {
|
||
try {
|
||
setColumnsLoading(true);
|
||
console.log(`🔍 컬럼 조회 중: ${tableName}`);
|
||
|
||
const columns = await tableTypeApi.getColumns(tableName);
|
||
|
||
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
|
||
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
|
||
const isNullableValue = col.is_nullable ?? col.isNullable;
|
||
let isNullable = true; // 기본값: nullable
|
||
|
||
if (typeof isNullableValue === "boolean") {
|
||
isNullable = isNullableValue;
|
||
} else if (typeof isNullableValue === "string") {
|
||
isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE";
|
||
} else if (typeof isNullableValue === "number") {
|
||
isNullable = isNullableValue !== 0;
|
||
}
|
||
|
||
return {
|
||
columnName: col.column_name || col.columnName,
|
||
columnLabel: col.label_ko || col.columnLabel,
|
||
dataType: col.data_type || col.dataType || "unknown",
|
||
isNullable,
|
||
columnDefault: col.column_default ?? col.columnDefault ?? null,
|
||
};
|
||
});
|
||
|
||
setTargetColumns(columnInfo);
|
||
console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`);
|
||
} catch (error) {
|
||
console.error("❌ 컬럼 목록 로딩 실패:", error);
|
||
setTargetColumns([]);
|
||
} finally {
|
||
setColumnsLoading(false);
|
||
}
|
||
};
|
||
|
||
// 🔥 외부 커넥션 로드 (캐시 우선)
|
||
const loadExternalConnections = async () => {
|
||
try {
|
||
// 캐시 확인
|
||
const cachedData = getExternalConnectionsCache();
|
||
if (cachedData) {
|
||
console.log("✅ 캐시된 외부 커넥션 사용:", cachedData.length);
|
||
setExternalConnections(cachedData);
|
||
return;
|
||
}
|
||
|
||
setExternalConnectionsLoading(true);
|
||
console.log("🔍 외부 커넥션 조회 중...");
|
||
|
||
const connections = await getTestedExternalConnections();
|
||
setExternalConnections(connections);
|
||
console.log(`✅ 외부 커넥션 ${connections.length}개 로딩 완료`);
|
||
} catch (error) {
|
||
console.error("❌ 외부 커넥션 로딩 실패:", error);
|
||
setExternalConnections([]);
|
||
} finally {
|
||
setExternalConnectionsLoading(false);
|
||
}
|
||
};
|
||
|
||
// 🔥 외부 테이블 로드
|
||
const loadExternalTables = async (connectionId: number) => {
|
||
try {
|
||
setExternalTablesLoading(true);
|
||
console.log(`🔍 외부 테이블 조회 중: connection ${connectionId}`);
|
||
|
||
const tables = await getExternalTables(connectionId);
|
||
setExternalTables(tables);
|
||
console.log(`✅ 외부 테이블 ${tables.length}개 로딩 완료`);
|
||
} catch (error) {
|
||
console.error("❌ 외부 테이블 로딩 실패:", error);
|
||
setExternalTables([]);
|
||
} finally {
|
||
setExternalTablesLoading(false);
|
||
}
|
||
};
|
||
|
||
// 🔥 외부 컬럼 로드
|
||
const loadExternalColumns = async (connectionId: number, tableName: string) => {
|
||
try {
|
||
setExternalColumnsLoading(true);
|
||
console.log(`🔍 외부 컬럼 조회 중: ${tableName}`);
|
||
|
||
const columns = await getExternalColumns(connectionId, tableName);
|
||
setExternalColumns(columns);
|
||
console.log(`✅ 외부 컬럼 ${columns.length}개 로딩 완료`);
|
||
} catch (error) {
|
||
console.error("❌ 외부 컬럼 로딩 실패:", error);
|
||
setExternalColumns([]);
|
||
} finally {
|
||
setExternalColumnsLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 테이블 선택 핸들러
|
||
*/
|
||
const handleTableSelect = (selectedTableName: string) => {
|
||
const selectedTable = tables.find((t) => t.tableName === selectedTableName);
|
||
if (selectedTable) {
|
||
setTargetTable(selectedTable.tableName);
|
||
if (!displayName || displayName === targetTable) {
|
||
setDisplayName(selectedTable.label);
|
||
}
|
||
|
||
// 즉시 노드 업데이트
|
||
updateNode(nodeId, {
|
||
displayName: selectedTable.label,
|
||
targetTable: selectedTable.tableName,
|
||
fieldMappings,
|
||
});
|
||
|
||
setTablesOpen(false);
|
||
}
|
||
};
|
||
|
||
const handleAddMapping = () => {
|
||
const newMappings = [
|
||
...fieldMappings,
|
||
{
|
||
sourceField: null,
|
||
targetField: "",
|
||
staticValue: undefined,
|
||
valueType: "source" as const, // 🔥 기본값: 소스 필드
|
||
},
|
||
];
|
||
setFieldMappings(newMappings);
|
||
updateNode(nodeId, { fieldMappings: newMappings });
|
||
|
||
// Combobox 열림 상태 배열 초기화
|
||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||
};
|
||
|
||
const handleRemoveMapping = (index: number) => {
|
||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||
setFieldMappings(newMappings);
|
||
updateNode(nodeId, { fieldMappings: newMappings });
|
||
|
||
// Combobox 열림 상태 배열도 업데이트
|
||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||
};
|
||
|
||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||
const newMappings = [...fieldMappings];
|
||
|
||
// 필드 변경 시 라벨도 함께 저장
|
||
if (field === "sourceField") {
|
||
const sourceField = sourceFields.find((f) => f.name === value);
|
||
newMappings[index] = {
|
||
...newMappings[index],
|
||
sourceField: value,
|
||
sourceFieldLabel: sourceField?.label,
|
||
};
|
||
} else if (field === "targetField") {
|
||
const targetColumn = (() => {
|
||
if (targetType === "internal") {
|
||
return targetColumns.find((col) => col.column_name === value);
|
||
} else if (targetType === "external") {
|
||
return externalColumns.find((col) => col.column_name === value);
|
||
}
|
||
return null;
|
||
})();
|
||
|
||
newMappings[index] = {
|
||
...newMappings[index],
|
||
targetField: value,
|
||
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
||
};
|
||
} else if (field === "valueType") {
|
||
// 🔥 값 생성 유형 변경 시 관련 필드 초기화
|
||
newMappings[index] = {
|
||
...newMappings[index],
|
||
valueType: value,
|
||
// 유형 변경 시 다른 유형의 값 초기화
|
||
...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }),
|
||
...(value !== "static" && { staticValue: undefined }),
|
||
...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }),
|
||
};
|
||
} else if (field === "numberingRuleId") {
|
||
// 🔥 채번 규칙 선택 시 이름도 함께 저장
|
||
const selectedRule = numberingRules.find((r) => r.ruleId === value);
|
||
newMappings[index] = {
|
||
...newMappings[index],
|
||
numberingRuleId: value,
|
||
numberingRuleName: selectedRule?.ruleName,
|
||
};
|
||
} else {
|
||
newMappings[index] = {
|
||
...newMappings[index],
|
||
[field]: value,
|
||
};
|
||
}
|
||
|
||
setFieldMappings(newMappings);
|
||
updateNode(nodeId, { fieldMappings: newMappings });
|
||
};
|
||
|
||
// 즉시 반영 핸들러들
|
||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||
setDisplayName(newDisplayName);
|
||
updateNode(nodeId, { displayName: newDisplayName });
|
||
};
|
||
|
||
const handleFieldMappingsChange = (newMappings: any[]) => {
|
||
setFieldMappings(newMappings);
|
||
updateNode(nodeId, { fieldMappings: newMappings });
|
||
};
|
||
|
||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||
|
||
// 🔥 타겟 타입 변경 핸들러
|
||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||
setTargetType(newType);
|
||
|
||
// 타입 변경 시 관련 필드 초기화
|
||
const updates: any = {
|
||
targetType: newType,
|
||
displayName,
|
||
};
|
||
|
||
// 이전 타입의 데이터 유지
|
||
if (newType === "internal") {
|
||
updates.targetTable = targetTable;
|
||
updates.targetTableLabel = data.targetTableLabel;
|
||
} else if (newType === "external") {
|
||
updates.externalConnectionId = data.externalConnectionId;
|
||
updates.externalTargetTable = data.externalTargetTable;
|
||
} else if (newType === "api") {
|
||
updates.apiEndpoint = data.apiEndpoint;
|
||
updates.apiMethod = data.apiMethod || "POST";
|
||
}
|
||
|
||
updates.fieldMappings = fieldMappings;
|
||
|
||
updateNode(nodeId, updates);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div className="space-y-4 p-4 pb-8">
|
||
{/* 🔥 타겟 타입 선택 */}
|
||
<div>
|
||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTargetTypeChange("internal")}
|
||
className={cn(
|
||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||
<span
|
||
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
|
||
>
|
||
내부 DB
|
||
</span>
|
||
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTargetTypeChange("external")}
|
||
className={cn(
|
||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||
targetType === "external" ? "border-green-500 bg-green-50" : "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||
<span
|
||
className={cn("text-xs font-medium", targetType === "external" ? "text-green-700" : "text-gray-600")}
|
||
>
|
||
외부 DB
|
||
</span>
|
||
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTargetTypeChange("api")}
|
||
className={cn(
|
||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||
<span className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}>
|
||
REST API
|
||
</span>
|
||
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 기본 정보 */}
|
||
<div>
|
||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label htmlFor="displayName" className="text-xs">
|
||
표시 이름
|
||
</Label>
|
||
<Input
|
||
id="displayName"
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
className="mt-1"
|
||
placeholder="노드 표시 이름"
|
||
/>
|
||
</div>
|
||
|
||
{/* 🔥 타겟 타입에 따른 조건부 렌더링 */}
|
||
{targetType === "internal" && (
|
||
<>
|
||
{/* 타겟 테이블 Combobox */}
|
||
<div>
|
||
<Label className="text-xs">타겟 테이블</Label>
|
||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={tablesOpen}
|
||
className="mt-1 w-full justify-between"
|
||
disabled={tablesLoading}
|
||
>
|
||
{tablesLoading ? (
|
||
<span className="text-muted-foreground">로딩 중...</span>
|
||
) : targetTable ? (
|
||
<span className="truncate">{selectedTableLabel}</span>
|
||
) : (
|
||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||
)}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-[320px] p-0" align="start">
|
||
<Command>
|
||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||
<CommandList>
|
||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||
<CommandGroup>
|
||
<ScrollArea className="h-[300px]">
|
||
{tables.map((table) => (
|
||
<CommandItem
|
||
key={table.tableName}
|
||
value={`${table.label} ${table.tableName} ${table.description}`}
|
||
onSelect={() => handleTableSelect(table.tableName)}
|
||
className="cursor-pointer"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{table.label}</span>
|
||
{table.label !== table.tableName && (
|
||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||
)}
|
||
{table.description && (
|
||
<span className="text-muted-foreground text-xs">{table.description}</span>
|
||
)}
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</ScrollArea>
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
{targetTable && selectedTableLabel !== targetTable && (
|
||
<p className="text-muted-foreground mt-1 text-xs">
|
||
실제 테이블명: <code className="rounded bg-gray-100 px-1 py-0.5">{targetTable}</code>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 🔥 외부 DB 타입 UI */}
|
||
{targetType === "external" && (
|
||
<>
|
||
{/* 외부 커넥션 선택 */}
|
||
<div>
|
||
<Label className="text-xs">외부 DB 커넥션</Label>
|
||
<Select
|
||
value={selectedExternalConnectionId?.toString()}
|
||
onValueChange={(value) => {
|
||
const connectionId = parseInt(value);
|
||
setSelectedExternalConnectionId(connectionId);
|
||
setExternalTargetTable(undefined); // 테이블 초기화
|
||
|
||
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
|
||
updateNode(nodeId, {
|
||
targetType,
|
||
displayName,
|
||
externalConnectionId: connectionId,
|
||
externalConnectionName: selectedConnection?.connection_name,
|
||
externalDbType: selectedConnection?.db_type,
|
||
externalTargetTable: undefined,
|
||
fieldMappings,
|
||
});
|
||
}}
|
||
disabled={externalConnectionsLoading}
|
||
>
|
||
<SelectTrigger className="mt-1">
|
||
<SelectValue placeholder="외부 커넥션을 선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{externalConnections.map((conn) => (
|
||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||
<div className="flex items-center gap-2">
|
||
<span>{conn.db_type.toUpperCase()}</span>
|
||
<span>-</span>
|
||
<span>{conn.connection_name}</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{externalConnectionsLoading && <p className="text-muted-foreground mt-1 text-xs">로딩 중...</p>}
|
||
{externalConnections.length === 0 && !externalConnectionsLoading && (
|
||
<p className="mt-1 text-xs text-orange-600">⚠️ 테스트에 성공한 외부 커넥션이 없습니다.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 외부 테이블 선택 */}
|
||
{selectedExternalConnectionId && (
|
||
<div>
|
||
<Label className="text-xs">외부 테이블</Label>
|
||
<Select
|
||
value={externalTargetTable}
|
||
onValueChange={(value) => {
|
||
setExternalTargetTable(value);
|
||
updateNode(nodeId, {
|
||
targetType,
|
||
displayName,
|
||
externalConnectionId: selectedExternalConnectionId,
|
||
externalTargetTable: value,
|
||
fieldMappings,
|
||
});
|
||
}}
|
||
disabled={externalTablesLoading}
|
||
>
|
||
<SelectTrigger className="mt-1">
|
||
<SelectValue placeholder="테이블을 선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{externalTables.map((table) => (
|
||
<SelectItem key={table.table_name} value={table.table_name}>
|
||
{table.table_name}
|
||
{table.table_schema && table.table_schema !== "public" && (
|
||
<span className="text-muted-foreground ml-2 text-xs">({table.table_schema})</span>
|
||
)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{externalTablesLoading && <p className="text-muted-foreground mt-1 text-xs">로딩 중...</p>}
|
||
</div>
|
||
)}
|
||
|
||
{/* 외부 컬럼 정보 표시 */}
|
||
{selectedExternalConnectionId && externalTargetTable && externalColumns.length > 0 && (
|
||
<div className="rounded-lg border bg-gray-50 p-3">
|
||
<p className="text-xs font-medium text-gray-700">타겟 컬럼 ({externalColumns.length}개)</p>
|
||
<div className="mt-2 max-h-[150px] space-y-1 overflow-y-auto">
|
||
{externalColumns.map((col) => (
|
||
<div key={col.column_name} className="flex items-center justify-between text-xs">
|
||
<span className="font-mono text-gray-700">{col.column_name}</span>
|
||
<span className="text-gray-500">{col.data_type}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 🔥 REST API 타입 UI (추후 구현) */}
|
||
{targetType === "api" && (
|
||
<div className="space-y-4">
|
||
{/* API 엔드포인트 */}
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||
<Input
|
||
placeholder="https://api.example.com/v1/users"
|
||
value={apiEndpoint}
|
||
onChange={(e) => {
|
||
setApiEndpoint(e.target.value);
|
||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
|
||
{/* HTTP 메서드 */}
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">HTTP 메서드</Label>
|
||
<Select
|
||
value={apiMethod}
|
||
onValueChange={(value: "POST" | "PUT" | "PATCH") => {
|
||
setApiMethod(value);
|
||
updateNode(nodeId, { apiMethod: value });
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="POST">POST</SelectItem>
|
||
<SelectItem value="PUT">PUT</SelectItem>
|
||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 인증 타입 */}
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">인증 방식</Label>
|
||
<Select
|
||
value={apiAuthType}
|
||
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
|
||
setApiAuthType(value);
|
||
updateNode(nodeId, { apiAuthType: value });
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">인증 없음</SelectItem>
|
||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||
<SelectItem value="apikey">API Key</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 인증 설정 */}
|
||
{apiAuthType !== "none" && (
|
||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||
<Label className="block text-xs font-medium">인증 정보</Label>
|
||
|
||
{apiAuthType === "bearer" && (
|
||
<Input
|
||
placeholder="Bearer Token"
|
||
value={(apiAuthConfig as any)?.token || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { token: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
)}
|
||
|
||
{apiAuthType === "basic" && (
|
||
<div className="space-y-2">
|
||
<Input
|
||
placeholder="사용자명"
|
||
value={(apiAuthConfig as any)?.username || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
placeholder="비밀번호"
|
||
value={(apiAuthConfig as any)?.password || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{apiAuthType === "apikey" && (
|
||
<div className="space-y-2">
|
||
<Input
|
||
placeholder="헤더 이름 (예: X-API-Key)"
|
||
value={(apiAuthConfig as any)?.headerName || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
<Input
|
||
placeholder="API Key"
|
||
value={(apiAuthConfig as any)?.apiKey || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 커스텀 헤더 */}
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">커스텀 헤더 (선택사항)</Label>
|
||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||
{Object.entries(apiHeaders).map(([key, value], index) => (
|
||
<div key={index} className="flex gap-2">
|
||
<Input
|
||
placeholder="헤더 이름"
|
||
value={key}
|
||
onChange={(e) => {
|
||
const newHeaders = { ...apiHeaders };
|
||
delete newHeaders[key];
|
||
newHeaders[e.target.value] = value;
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 flex-1 text-xs"
|
||
/>
|
||
<Input
|
||
placeholder="헤더 값"
|
||
value={value}
|
||
onChange={(e) => {
|
||
const newHeaders = { ...apiHeaders, [key]: e.target.value };
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 flex-1 text-xs"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => {
|
||
const newHeaders = { ...apiHeaders };
|
||
delete newHeaders[key];
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
const newHeaders = { ...apiHeaders, "": "" };
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 w-full text-xs"
|
||
>
|
||
<Plus className="mr-1 h-3 w-3" />
|
||
헤더 추가
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 요청 바디 설정 */}
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">
|
||
요청 바디 템플릿
|
||
<span className="ml-1 text-gray-500">{"{{fieldName}}"}으로 소스 필드 참조</span>
|
||
</Label>
|
||
<textarea
|
||
placeholder={'{\n "name": "{{name}}",\n "email": "{{email}}",\n "age": "{{age}}"\n}'}
|
||
value={apiBodyTemplate}
|
||
onChange={(e) => {
|
||
setApiBodyTemplate(e.target.value);
|
||
updateNode(nodeId, { apiBodyTemplate: e.target.value });
|
||
}}
|
||
className="w-full rounded border p-2 font-mono text-xs"
|
||
rows={8}
|
||
/>
|
||
<p className="mt-1 text-xs text-gray-500">
|
||
소스 데이터의 필드명을 {"{{필드명}}"} 형태로 참조할 수 있습니다.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
|
||
{targetType !== "api" && (
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7">
|
||
<Plus className="mr-1 h-3 w-3" />
|
||
추가
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 🔥 로딩 상태 (타입별) */}
|
||
{(columnsLoading || externalColumnsLoading) && (
|
||
<div className="rounded border p-3 text-center text-xs text-gray-500">컬럼 목록을 불러오는 중...</div>
|
||
)}
|
||
|
||
{/* 🔥 테이블 미선택 경고 (타입별) */}
|
||
{targetType === "internal" && !targetTable && !columnsLoading && (
|
||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||
</div>
|
||
)}
|
||
|
||
{targetType === "external" && !externalTargetTable && !externalColumnsLoading && (
|
||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||
⚠️ 먼저 외부 커넥션과 테이블을 선택하세요
|
||
</div>
|
||
)}
|
||
|
||
{/* 🔥 컬럼 로드 실패 (타입별) */}
|
||
{targetType === "internal" && targetTable && !columnsLoading && targetColumns.length === 0 && (
|
||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||
❌ 컬럼 정보를 불러올 수 없습니다
|
||
</div>
|
||
)}
|
||
|
||
{targetType === "external" &&
|
||
externalTargetTable &&
|
||
!externalColumnsLoading &&
|
||
externalColumns.length === 0 && (
|
||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||
❌ 외부 DB 컬럼 정보를 불러올 수 없습니다
|
||
</div>
|
||
)}
|
||
|
||
{/* 🔥 필드 매핑 UI (타입별 컬럼 사용) */}
|
||
{((targetType === "internal" && targetTable && targetColumns.length > 0) ||
|
||
(targetType === "external" && externalTargetTable && externalColumns.length > 0)) && (
|
||
<>
|
||
{fieldMappings.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{fieldMappings.map((mapping, index) => (
|
||
<div key={index} className="rounded border bg-gray-50 p-3">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-xs font-medium text-gray-700">매핑 #{index + 1}</span>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => handleRemoveMapping(index)}
|
||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{/* 🔥 값 생성 유형 선택 */}
|
||
<div>
|
||
<Label className="text-xs text-gray-600">값 생성 방식</Label>
|
||
<div className="mt-1 grid grid-cols-3 gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleMappingChange(index, "valueType", "source")}
|
||
className={cn(
|
||
"rounded border px-2 py-1 text-xs transition-all",
|
||
(mapping.valueType === "source" || !mapping.valueType)
|
||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||
: "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
소스 필드
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleMappingChange(index, "valueType", "static")}
|
||
className={cn(
|
||
"rounded border px-2 py-1 text-xs transition-all",
|
||
mapping.valueType === "static"
|
||
? "border-orange-500 bg-orange-50 text-orange-700"
|
||
: "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
고정값
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleMappingChange(index, "valueType", "autoGenerate")}
|
||
className={cn(
|
||
"rounded border px-2 py-1 text-xs transition-all flex items-center justify-center gap-1",
|
||
mapping.valueType === "autoGenerate"
|
||
? "border-purple-500 bg-purple-50 text-purple-700"
|
||
: "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
<Sparkles className="h-3 w-3" />
|
||
자동생성
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */}
|
||
{(mapping.valueType === "source" || !mapping.valueType) && (
|
||
<div>
|
||
<Label className="text-xs text-gray-600">
|
||
소스 필드
|
||
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||
</Label>
|
||
{hasRestAPISource ? (
|
||
// REST API 소스인 경우: 직접 입력
|
||
<Input
|
||
value={mapping.sourceField || ""}
|
||
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||
placeholder="필드명 입력 (예: userId, userName)"
|
||
className="mt-1 h-8 text-xs"
|
||
/>
|
||
) : (
|
||
// 일반 소스인 경우: Combobox 선택
|
||
<Popover
|
||
open={mappingSourceFieldsOpenState[index]}
|
||
onOpenChange={(open) => {
|
||
const newState = [...mappingSourceFieldsOpenState];
|
||
newState[index] = open;
|
||
setMappingSourceFieldsOpenState(newState);
|
||
}}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||
>
|
||
{mapping.sourceField
|
||
? (() => {
|
||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||
return (
|
||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||
<span className="truncate font-medium">
|
||
{field?.label || mapping.sourceField}
|
||
</span>
|
||
{field?.label && field.label !== field.name && (
|
||
<span className="text-muted-foreground font-mono text-xs">
|
||
{field.name}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})()
|
||
: "소스 필드 선택"}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||
align="start"
|
||
>
|
||
<Command>
|
||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||
<CommandList>
|
||
<CommandEmpty className="text-xs sm:text-sm">
|
||
필드를 찾을 수 없습니다.
|
||
</CommandEmpty>
|
||
<CommandGroup>
|
||
{sourceFields.map((field) => (
|
||
<CommandItem
|
||
key={field.name}
|
||
value={field.name}
|
||
onSelect={(currentValue) => {
|
||
handleMappingChange(index, "sourceField", currentValue || null);
|
||
const newState = [...mappingSourceFieldsOpenState];
|
||
newState[index] = false;
|
||
setMappingSourceFieldsOpenState(newState);
|
||
}}
|
||
className="text-xs sm:text-sm"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{field.label || field.name}</span>
|
||
{field.label && field.label !== field.name && (
|
||
<span className="text-muted-foreground font-mono text-[10px]">
|
||
{field.name}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
)}
|
||
{hasRestAPISource && (
|
||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 🔥 고정값 입력 (valueType === "static" 일 때) */}
|
||
{mapping.valueType === "static" && (
|
||
<div>
|
||
<Label className="text-xs text-gray-600">고정값</Label>
|
||
<Input
|
||
value={mapping.staticValue || ""}
|
||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||
placeholder="고정값 입력"
|
||
className="mt-1 h-8 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */}
|
||
{mapping.valueType === "autoGenerate" && (
|
||
<div>
|
||
<Label className="text-xs text-gray-600">
|
||
채번 규칙
|
||
{numberingRulesLoading && <span className="ml-1 text-gray-400">(로딩 중...)</span>}
|
||
</Label>
|
||
<Popover
|
||
open={mappingNumberingRulesOpenState[index]}
|
||
onOpenChange={(open) => {
|
||
const newState = [...mappingNumberingRulesOpenState];
|
||
newState[index] = open;
|
||
setMappingNumberingRulesOpenState(newState);
|
||
}}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={mappingNumberingRulesOpenState[index]}
|
||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||
disabled={numberingRulesLoading || numberingRules.length === 0}
|
||
>
|
||
{mapping.numberingRuleId
|
||
? (() => {
|
||
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId);
|
||
return (
|
||
<div className="flex items-center gap-2 overflow-hidden">
|
||
<Sparkles className="h-3 w-3 text-purple-500" />
|
||
<span className="truncate font-medium">
|
||
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId}
|
||
</span>
|
||
</div>
|
||
);
|
||
})()
|
||
: "채번 규칙 선택"}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||
align="start"
|
||
>
|
||
<Command>
|
||
<CommandInput placeholder="채번 규칙 검색..." className="text-xs sm:text-sm" />
|
||
<CommandList>
|
||
<CommandEmpty className="text-xs sm:text-sm">
|
||
채번 규칙을 찾을 수 없습니다.
|
||
</CommandEmpty>
|
||
<CommandGroup>
|
||
{numberingRules.map((rule) => (
|
||
<CommandItem
|
||
key={rule.ruleId}
|
||
value={rule.ruleId}
|
||
onSelect={(currentValue) => {
|
||
handleMappingChange(index, "numberingRuleId", currentValue);
|
||
const newState = [...mappingNumberingRulesOpenState];
|
||
newState[index] = false;
|
||
setMappingNumberingRulesOpenState(newState);
|
||
}}
|
||
className="text-xs sm:text-sm"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
mapping.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{rule.ruleName}</span>
|
||
<span className="text-muted-foreground font-mono text-[10px]">
|
||
{rule.ruleId}
|
||
{rule.tableName && ` - ${rule.tableName}`}
|
||
</span>
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
{numberingRules.length === 0 && !numberingRulesLoading && (
|
||
<p className="mt-1 text-xs text-orange-600">
|
||
등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-center py-1">
|
||
<ArrowRight className="h-4 w-4 text-green-600" />
|
||
</div>
|
||
|
||
{/* 타겟 필드 Combobox (🔥 타입별 컬럼 사용) */}
|
||
<div>
|
||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||
<Popover
|
||
open={mappingTargetFieldsOpenState[index]}
|
||
onOpenChange={(open) => {
|
||
const newState = [...mappingTargetFieldsOpenState];
|
||
newState[index] = open;
|
||
setMappingTargetFieldsOpenState(newState);
|
||
}}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={mappingTargetFieldsOpenState[index]}
|
||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||
>
|
||
{mapping.targetField
|
||
? (() => {
|
||
if (targetType === "internal") {
|
||
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
|
||
return (
|
||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||
<span className="truncate font-mono">
|
||
{col?.columnLabel || mapping.targetField}
|
||
</span>
|
||
<span className="text-muted-foreground text-xs">
|
||
{col?.dataType}
|
||
{col && !col.isNullable && <span className="text-red-500">*</span>}
|
||
</span>
|
||
</div>
|
||
);
|
||
} else {
|
||
const col = externalColumns.find(
|
||
(c) => c.column_name === mapping.targetField,
|
||
);
|
||
return (
|
||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||
<span className="truncate font-mono">
|
||
{col?.column_name || mapping.targetField}
|
||
</span>
|
||
<span className="text-muted-foreground text-xs">{col?.data_type}</span>
|
||
</div>
|
||
);
|
||
}
|
||
})()
|
||
: "타겟 필드 선택"}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||
align="start"
|
||
>
|
||
<Command>
|
||
<CommandInput placeholder="타겟 필드 검색..." className="text-xs sm:text-sm" />
|
||
<CommandList>
|
||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||
<CommandGroup>
|
||
{/* 🔥 내부 DB 컬럼 */}
|
||
{targetType === "internal" &&
|
||
targetColumns.map((col) => (
|
||
<CommandItem
|
||
key={col.columnName}
|
||
value={col.columnName}
|
||
onSelect={(currentValue) => {
|
||
handleMappingChange(index, "targetField", currentValue);
|
||
const newState = [...mappingTargetFieldsOpenState];
|
||
newState[index] = false;
|
||
setMappingTargetFieldsOpenState(newState);
|
||
}}
|
||
className="text-xs sm:text-sm"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-mono font-medium">
|
||
{col.columnLabel || col.columnName}
|
||
</span>
|
||
<span className="text-muted-foreground text-[10px]">
|
||
{col.dataType}
|
||
{col.columnDefault ? (
|
||
<span className="text-blue-600"> 🔵기본값</span>
|
||
) : (
|
||
!col.isNullable && <span className="text-red-500"> *필수</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
|
||
{/* 🔥 외부 DB 컬럼 */}
|
||
{targetType === "external" &&
|
||
externalColumns.map((col) => (
|
||
<CommandItem
|
||
key={col.column_name}
|
||
value={col.column_name}
|
||
onSelect={(currentValue) => {
|
||
handleMappingChange(index, "targetField", currentValue);
|
||
const newState = [...mappingTargetFieldsOpenState];
|
||
newState[index] = false;
|
||
setMappingTargetFieldsOpenState(newState);
|
||
}}
|
||
className="text-xs sm:text-sm"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
mapping.targetField === col.column_name ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-mono font-medium">{col.column_name}</span>
|
||
<span className="text-muted-foreground text-[10px]">{col.data_type}</span>
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||
필드 매핑이 없습니다. "추가" 버튼을 클릭하세요.
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 안내 */}
|
||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||
<p>테이블과 필드는 실제 데이터베이스에서 조회됩니다.</p>
|
||
<p className="mt-1">값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|