ERP-node/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx

1298 lines
56 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

"use client";
/**
* INSERT 액션 노드 속성 편집 (개선 버전)
*/
import { useEffect, useState } from "react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
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 { 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 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;
}
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 || []);
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false);
// 내부 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);
// 🔥 외부 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 || "");
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || data.targetTable);
setTargetTable(data.targetTable);
setFieldMappings(data.fieldMappings || []);
setBatchSize(data.options?.batchSize?.toString() || "");
setIgnoreErrors(data.options?.ignoreErrors || false);
setIgnoreDuplicates(data.options?.ignoreDuplicates || false);
}, [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]);
// 🔥 외부 테이블 변경 시 컬럼 로드
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⃣ 테이블/외부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;
}
}
// 4⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
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) => ({
columnName: col.column_name || col.columnName,
columnLabel: col.label_ko || col.columnLabel,
dataType: col.data_type || col.dataType || "unknown",
isNullable: col.is_nullable === "YES" || col.isNullable === true,
}));
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,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
setTablesOpen(false);
}
};
const handleAddMapping = () => {
const newMappings = [
...fieldMappings,
{
sourceField: null,
targetField: "",
staticValue: undefined,
},
];
setFieldMappings(newMappings);
updateNode(nodeId, { fieldMappings: newMappings });
};
const handleRemoveMapping = (index: number) => {
const newMappings = fieldMappings.filter((_, i) => i !== index);
setFieldMappings(newMappings);
updateNode(nodeId, { fieldMappings: newMappings });
};
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 {
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 handleBatchSizeChange = (newBatchSize: string) => {
setBatchSize(newBatchSize);
updateNode(nodeId, {
options: {
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
};
const handleIgnoreErrorsChange = (checked: boolean) => {
setIgnoreErrors(checked);
updateNode(nodeId, {
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors: checked,
ignoreDuplicates,
},
});
};
const handleIgnoreDuplicatesChange = (checked: boolean) => {
setIgnoreDuplicates(checked);
updateNode(nodeId, {
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates: checked,
},
});
};
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;
updates.options = {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
};
updateNode(nodeId, updates);
};
return (
<ScrollArea className="h-full">
<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,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
}}
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,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
}}
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">
{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"
/>
) : (
// 일반 소스인 경우: 드롭다운 선택
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400">
</div>
) : (
sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name} className="text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">
{field.name}
</span>
)}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
{hasRestAPISource && (
<p className="mt-1 text-xs text-gray-500">API JSON의 </p>
)}
</div>
<div className="flex items-center justify-center py-1">
<ArrowRight className="h-4 w-4 text-green-600" />
</div>
{/* 타겟 필드 드롭다운 (🔥 타입별 컬럼 사용) */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={mapping.targetField}
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="타겟 필드 선택" />
</SelectTrigger>
<SelectContent>
{/* 🔥 내부 DB 컬럼 */}
{targetType === "internal" &&
targetColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-mono">{col.columnLabel || col.columnName}</span>
<span className="text-muted-foreground">
{col.dataType}
{!col.isNullable && <span className="text-red-500">*</span>}
</span>
</div>
</SelectItem>
))}
{/* 🔥 외부 DB 컬럼 */}
{targetType === "external" &&
externalColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-mono">{col.column_name}</span>
<span className="text-muted-foreground">{col.data_type}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정적 값 */}
<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"
/>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
. "추가" .
</div>
)}
</>
)}
</div>
)}
{/* 옵션 */}
<div>
<h3 className="mb-3 text-sm font-semibold"></h3>
<div className="space-y-3">
<div>
<Label htmlFor="batchSize" className="text-xs">
</Label>
<Input
id="batchSize"
type="number"
value={batchSize}
onChange={(e) => handleBatchSizeChange(e.target.value)}
className="mt-1"
placeholder="한 번에 처리할 레코드 수"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="ignoreDuplicates"
checked={ignoreDuplicates}
onCheckedChange={(checked) => handleIgnoreDuplicatesChange(checked as boolean)}
/>
<Label htmlFor="ignoreDuplicates" className="text-xs font-normal">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="ignoreErrors"
checked={ignoreErrors}
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
/>
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
</Label>
</div>
</div>
</div>
{/* 저장 버튼 */}
{/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
.
<br />
💡 .
</div>
</div>
</ScrollArea>
);
}