"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(data.conflictKeys || []); const [conflictKeyLabels, setConflictKeyLabels] = useState(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([]); const [tablesLoading, setTablesLoading] = useState(false); const [tablesOpen, setTablesOpen] = useState(false); // 컬럼 관련 상태 const [targetColumns, setTargetColumns] = useState([]); const [columnsLoading, setColumnsLoading] = useState(false); // 소스 필드 목록 (연결된 입력 노드에서 가져오기) const [sourceFields, setSourceFields] = useState>([]); // 데이터 변경 시 로컬 상태 업데이트 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(), ): 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(); 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 (
{/* 기본 정보 */}

기본 정보

setDisplayName(e.target.value)} className="mt-1" placeholder="노드 표시 이름" />
{/* 타겟 테이블 Combobox */}
테이블을 찾을 수 없습니다. {tables.map((table) => ( handleTableSelect(table.tableName)} >
{table.label || table.displayName} {table.tableName}
))}
{/* 충돌 키 (ON CONFLICT) */}

충돌 키 (ON CONFLICT)

중복 체크에 사용할 키를 선택하세요

{!targetTable && (
⚠️ 먼저 타겟 테이블을 선택하세요
)} {targetTable && targetColumns.length > 0 && ( <> {/* 선택된 충돌 키 */} {conflictKeys.length > 0 ? (
{conflictKeys.map((key, idx) => (
{conflictKeyLabels[idx] || key}
))}
) : (
충돌 키를 추가하세요
)} {/* 충돌 키 추가 드롭다운 */} )}
{/* 필드 매핑 */}

필드 매핑

{!targetTable && !columnsLoading && (
⚠️ 먼저 타겟 테이블을 선택하세요
)} {targetTable && !columnsLoading && targetColumns.length === 0 && (
❌ 컬럼 정보를 불러올 수 없습니다
)} {targetTable && targetColumns.length > 0 && ( <> {fieldMappings.length > 0 ? (
{fieldMappings.map((mapping, index) => (
매핑 #{index + 1}
{/* 소스 필드 드롭다운 */}
{/* 타겟 필드 드롭다운 */}
{/* 정적 값 */}
handleMappingChange(index, "staticValue", e.target.value || undefined)} placeholder="소스 필드 대신 고정 값 사용" className="mt-1 h-8 text-xs" />

소스 필드가 비어있을 때만 사용됩니다

))}
) : (
UPSERT할 필드를 추가하세요
)} )}
{/* 옵션 */}

옵션

setBatchSize(e.target.value)} className="mt-1" placeholder="예: 100" />
setUpdateOnConflict(checked as boolean)} />
{/* 적용 버튼 */}

✅ 변경 사항이 즉시 노드에 반영됩니다.

); }