Files
vexplor/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx
2025-10-02 17:51:15 +09:00

1173 lines
50 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 }>>([]);
// 🔥 외부 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>(),
): Array<{ name: string; label?: string }> => {
if (visitedNodes.has(targetNodeId)) {
return [];
}
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));
const fields: Array<{ name: string; label?: string }> = [];
sourceNodes.forEach((node) => {
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
console.log(`🔍 노드 ${node.id} 데이터:`, node.data);
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
if (node.type === "dataTransform") {
console.log(`✅ 데이터 변환 노드 발견`);
// 상위 노드의 원본 필드 먼저 수집
const upperFields = getAllSourceFields(node.id, visitedNodes);
console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`);
// 변환된 필드 추가 (in-place 변환 고려)
if (node.data.transformations && Array.isArray(node.data.transformations)) {
console.log(` 📊 ${node.data.transformations.length}개 변환 발견`);
const inPlaceFields = new Set<string>(); // in-place 변환된 필드 추적
node.data.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) {
// in-place: 원본 필드를 덮어쓰므로, 원본 필드는 이미 upperFields에 있음
inPlaceFields.add(transform.sourceField);
} else if (targetField) {
// 새 필드 생성
fields.push({
name: targetField,
label: transform.targetFieldLabel || targetField,
});
}
});
// 상위 필드 중 in-place 변환되지 않은 것만 추가
upperFields.forEach((field) => {
if (!inPlaceFields.has(field.name)) {
fields.push(field);
} else {
// in-place 변환된 필드도 추가 (변환 후 값)
fields.push(field);
}
});
} else {
// 변환이 없으면 상위 필드만 추가
fields.push(...upperFields);
}
}
// 일반 소스 노드인 경우
else {
const nodeFields = node.data.fields || node.data.outputFields;
if (nodeFields && Array.isArray(nodeFields)) {
console.log(`✅ 노드 ${node.id}에서 ${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) {
fields.push({
name: fieldName,
label: fieldLabel,
});
}
});
} else {
console.log(`❌ 노드 ${node.id}에 fields 없음`);
}
}
});
return fields;
};
console.log("🔍 INSERT 노드 ID:", nodeId);
const allFields = getAllSourceFields(nodeId);
// 중복 제거
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
setSourceFields(uniqueFields);
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
}, [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 = () => {
setFieldMappings([
...fieldMappings,
{
sourceField: null,
targetField: "",
staticValue: undefined,
},
]);
};
const handleRemoveMapping = (index: number) => {
const newMappings = fieldMappings.filter((_, i) => i !== index);
setFieldMappings(newMappings);
// 즉시 반영
updateNode(nodeId, {
displayName,
targetTable,
fieldMappings: newMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
};
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 = targetColumns.find((c) => c.columnName === value);
newMappings[index] = {
...newMappings[index],
targetField: value,
targetFieldLabel: targetColumn?.columnLabel,
};
} else {
newMappings[index] = { ...newMappings[index], [field]: value };
}
setFieldMappings(newMappings);
};
const handleSave = () => {
updateNode(nodeId, {
displayName,
targetTable,
fieldMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
};
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">
{/* 🔥 타겟 타입 선택 */}
<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"> </Label>
<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>
</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) => setBatchSize(e.target.value)}
className="mt-1"
placeholder="한 번에 처리할 레코드 수"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="ignoreDuplicates"
checked={ignoreDuplicates}
onCheckedChange={(checked) => setIgnoreDuplicates(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) => setIgnoreErrors(checked as boolean)}
/>
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
</Label>
</div>
</div>
</div>
{/* 저장 버튼 */}
<div className="flex gap-2">
<Button onClick={handleSave} className="flex-1" size="sm">
</Button>
</div>
{/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
.
<br />
💡 .
</div>
</div>
</ScrollArea>
);
}