639 lines
24 KiB
TypeScript
639 lines
24 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* UPSERT 액션 노드 속성 편집 (개선 버전)
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { useEffect, useState } from "react";
|
|||
|
|
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } 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 type { UpsertActionNodeData } from "@/types/node-editor";
|
|||
|
|
|
|||
|
|
interface UpsertActionPropertiesProps {
|
|||
|
|
nodeId: string;
|
|||
|
|
data: UpsertActionNodeData;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface TableOption {
|
|||
|
|
tableName: string;
|
|||
|
|
displayName: string;
|
|||
|
|
description: string;
|
|||
|
|
label: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ColumnInfo {
|
|||
|
|
columnName: string;
|
|||
|
|
columnLabel?: string;
|
|||
|
|
dataType: string;
|
|||
|
|
isNullable: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
|||
|
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
|||
|
|
|
|||
|
|
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
|||
|
|
const [targetTable, setTargetTable] = useState(data.targetTable);
|
|||
|
|
const [conflictKeys, setConflictKeys] = useState<string[]>(data.conflictKeys || []);
|
|||
|
|
const [conflictKeyLabels, setConflictKeyLabels] = useState<string[]>(data.conflictKeyLabels || []);
|
|||
|
|
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
|||
|
|
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
|||
|
|
const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true);
|
|||
|
|
|
|||
|
|
// 테이블 관련 상태
|
|||
|
|
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 }>>([]);
|
|||
|
|
|
|||
|
|
// 데이터 변경 시 로컬 상태 업데이트
|
|||
|
|
useEffect(() => {
|
|||
|
|
setDisplayName(data.displayName || data.targetTable);
|
|||
|
|
setTargetTable(data.targetTable);
|
|||
|
|
setConflictKeys(data.conflictKeys || []);
|
|||
|
|
setConflictKeyLabels(data.conflictKeyLabels || []);
|
|||
|
|
setFieldMappings(data.fieldMappings || []);
|
|||
|
|
setBatchSize(data.options?.batchSize?.toString() || "");
|
|||
|
|
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
|
|||
|
|
}, [data]);
|
|||
|
|
|
|||
|
|
// 테이블 목록 로딩
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadTables();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// 타겟 테이블 변경 시 컬럼 로딩
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (targetTable) {
|
|||
|
|
loadColumns(targetTable);
|
|||
|
|
}
|
|||
|
|
}, [targetTable]);
|
|||
|
|
|
|||
|
|
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
|||
|
|
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) => {
|
|||
|
|
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
|||
|
|
if (node.type === "dataTransform") {
|
|||
|
|
// 상위 노드의 원본 필드 먼저 수집
|
|||
|
|
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
|||
|
|
|
|||
|
|
// 변환된 필드 추가 (in-place 변환 고려)
|
|||
|
|
if (node.data.transformations && Array.isArray(node.data.transformations)) {
|
|||
|
|
const inPlaceFields = new Set<string>();
|
|||
|
|
|
|||
|
|
node.data.transformations.forEach((transform: any) => {
|
|||
|
|
const targetField = transform.targetField || transform.sourceField;
|
|||
|
|
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
|||
|
|
|
|||
|
|
if (isInPlace) {
|
|||
|
|
inPlaceFields.add(transform.sourceField);
|
|||
|
|
} else if (targetField) {
|
|||
|
|
fields.push({
|
|||
|
|
name: targetField,
|
|||
|
|
label: transform.targetFieldLabel || targetField,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 상위 필드 추가 (모두 포함, in-place는 변환 후 값)
|
|||
|
|
upperFields.forEach((field) => {
|
|||
|
|
fields.push(field);
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
fields.push(...upperFields);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 일반 소스 노드인 경우
|
|||
|
|
else if (node.type === "tableSource" && node.data.fields) {
|
|||
|
|
node.data.fields.forEach((field: any) => {
|
|||
|
|
fields.push({
|
|||
|
|
name: field.name,
|
|||
|
|
label: field.label || field.displayName,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
} else if (node.type === "externalDBSource" && node.data.fields) {
|
|||
|
|
node.data.fields.forEach((field: any) => {
|
|||
|
|
fields.push({
|
|||
|
|
name: field.name,
|
|||
|
|
label: field.label || field.displayName,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return fields;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const allFields = getAllSourceFields(nodeId);
|
|||
|
|
|
|||
|
|
// 중복 제거
|
|||
|
|
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
|
|||
|
|
|
|||
|
|
setSourceFields(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);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error("❌ UPSERT 노드 - 테이블 목록 로딩 실패:", error);
|
|||
|
|
} finally {
|
|||
|
|
setTablesLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const loadColumns = async (tableName: string) => {
|
|||
|
|
try {
|
|||
|
|
setColumnsLoading(true);
|
|||
|
|
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);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error("❌ UPSERT 노드 - 컬럼 목록 로딩 실패:", error);
|
|||
|
|
setTargetColumns([]);
|
|||
|
|
} finally {
|
|||
|
|
setColumnsLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleTableSelect = async (newTableName: string) => {
|
|||
|
|
const selectedTable = tables.find((t) => t.tableName === newTableName);
|
|||
|
|
const newDisplayName = selectedTable?.label || selectedTable?.displayName || newTableName;
|
|||
|
|
|
|||
|
|
setTargetTable(newTableName);
|
|||
|
|
setDisplayName(newDisplayName);
|
|||
|
|
|
|||
|
|
await loadColumns(newTableName);
|
|||
|
|
|
|||
|
|
// 즉시 반영
|
|||
|
|
updateNode(nodeId, {
|
|||
|
|
displayName: newDisplayName,
|
|||
|
|
targetTable: newTableName,
|
|||
|
|
conflictKeys,
|
|||
|
|
conflictKeyLabels,
|
|||
|
|
fieldMappings,
|
|||
|
|
options: {
|
|||
|
|
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
|||
|
|
updateOnConflict,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
setTablesOpen(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleAddConflictKey = (columnName: string) => {
|
|||
|
|
if (!conflictKeys.includes(columnName)) {
|
|||
|
|
const column = targetColumns.find((c) => c.columnName === columnName);
|
|||
|
|
const newConflictKeys = [...conflictKeys, columnName];
|
|||
|
|
const newConflictKeyLabels = [...conflictKeyLabels, column?.columnLabel || columnName];
|
|||
|
|
|
|||
|
|
setConflictKeys(newConflictKeys);
|
|||
|
|
setConflictKeyLabels(newConflictKeyLabels);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleRemoveConflictKey = (index: number) => {
|
|||
|
|
const newKeys = conflictKeys.filter((_, i) => i !== index);
|
|||
|
|
const newLabels = conflictKeyLabels.filter((_, i) => i !== index);
|
|||
|
|
|
|||
|
|
setConflictKeys(newKeys);
|
|||
|
|
setConflictKeyLabels(newLabels);
|
|||
|
|
|
|||
|
|
// 즉시 반영
|
|||
|
|
updateNode(nodeId, {
|
|||
|
|
displayName,
|
|||
|
|
targetTable,
|
|||
|
|
conflictKeys: newKeys,
|
|||
|
|
conflictKeyLabels: newLabels,
|
|||
|
|
fieldMappings,
|
|||
|
|
options: {
|
|||
|
|
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
|||
|
|
updateOnConflict,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
conflictKeys,
|
|||
|
|
conflictKeyLabels,
|
|||
|
|
fieldMappings: newMappings,
|
|||
|
|
options: {
|
|||
|
|
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
|||
|
|
updateOnConflict,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
conflictKeys,
|
|||
|
|
conflictKeyLabels,
|
|||
|
|
fieldMappings,
|
|||
|
|
options: {
|
|||
|
|
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
|||
|
|
updateOnConflict,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<ScrollArea className="h-full">
|
|||
|
|
<div className="space-y-4 p-4">
|
|||
|
|
{/* 기본 정보 */}
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
{/* 타겟 테이블 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" />
|
|||
|
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
|||
|
|
<CommandList>
|
|||
|
|
<CommandGroup>
|
|||
|
|
{tables.map((table) => (
|
|||
|
|
<CommandItem
|
|||
|
|
key={table.tableName}
|
|||
|
|
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
|||
|
|
onSelect={() => handleTableSelect(table.tableName)}
|
|||
|
|
>
|
|||
|
|
<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 || table.displayName}</span>
|
|||
|
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
|||
|
|
</div>
|
|||
|
|
</CommandItem>
|
|||
|
|
))}
|
|||
|
|
</CommandGroup>
|
|||
|
|
</CommandList>
|
|||
|
|
</Command>
|
|||
|
|
</PopoverContent>
|
|||
|
|
</Popover>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 충돌 키 (ON CONFLICT) */}
|
|||
|
|
<div>
|
|||
|
|
<div className="mb-2 flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm font-semibold">충돌 키 (ON CONFLICT)</h3>
|
|||
|
|
<p className="text-xs text-gray-500">중복 체크에 사용할 키를 선택하세요</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{!targetTable && (
|
|||
|
|
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
|||
|
|
⚠️ 먼저 타겟 테이블을 선택하세요
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{targetTable && targetColumns.length > 0 && (
|
|||
|
|
<>
|
|||
|
|
{/* 선택된 충돌 키 */}
|
|||
|
|
{conflictKeys.length > 0 ? (
|
|||
|
|
<div className="mb-3 flex flex-wrap gap-2">
|
|||
|
|
{conflictKeys.map((key, idx) => (
|
|||
|
|
<div key={idx} className="flex items-center gap-1 rounded bg-purple-100 px-2 py-1 text-xs">
|
|||
|
|
<span className="font-medium text-purple-700">{conflictKeyLabels[idx] || key}</span>
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleRemoveConflictKey(idx)}
|
|||
|
|
className="ml-1 text-purple-600 hover:text-purple-800"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="h-3 w-3" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="mb-3 rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
|||
|
|
충돌 키를 추가하세요
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 충돌 키 추가 드롭다운 */}
|
|||
|
|
<Select onValueChange={handleAddConflictKey}>
|
|||
|
|
<SelectTrigger className="h-8 text-xs">
|
|||
|
|
<SelectValue placeholder="충돌 키 추가..." />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{targetColumns
|
|||
|
|
.filter((col) => !conflictKeys.includes(col.columnName))
|
|||
|
|
.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}</span>
|
|||
|
|
</div>
|
|||
|
|
</SelectItem>
|
|||
|
|
))}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 필드 매핑 */}
|
|||
|
|
<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 px-2 text-xs">
|
|||
|
|
<Plus className="mr-1 h-3 w-3" />
|
|||
|
|
매핑 추가
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{!targetTable && !columnsLoading && (
|
|||
|
|
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
|||
|
|
⚠️ 먼저 타겟 테이블을 선택하세요
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
|||
|
|
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
|||
|
|
❌ 컬럼 정보를 불러올 수 없습니다
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{targetTable && targetColumns.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-purple-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>
|
|||
|
|
{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>
|
|||
|
|
))}
|
|||
|
|
</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 bg-gray-50 p-3 text-center text-xs text-gray-500">
|
|||
|
|
UPSERT할 필드를 추가하세요
|
|||
|
|
</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="예: 100"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<Checkbox
|
|||
|
|
id="updateOnConflict"
|
|||
|
|
checked={updateOnConflict}
|
|||
|
|
onCheckedChange={(checked) => setUpdateOnConflict(checked as boolean)}
|
|||
|
|
/>
|
|||
|
|
<Label htmlFor="updateOnConflict" className="cursor-pointer text-xs font-normal">
|
|||
|
|
충돌 시 업데이트 (ON CONFLICT DO UPDATE)
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 적용 버튼 */}
|
|||
|
|
<div className="sticky bottom-0 border-t bg-white pt-3">
|
|||
|
|
<Button onClick={handleSave} className="w-full" size="sm">
|
|||
|
|
적용
|
|||
|
|
</Button>
|
|||
|
|
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
);
|
|||
|
|
}
|