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

614 lines
24 KiB
TypeScript
Raw Normal View History

feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
"use client";
/**
* INSERT ( )
*/
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 { InsertActionNodeData } from "@/types/node-editor";
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 } = useFlowEditorStore();
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);
// 테이블 관련 상태
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);
setFieldMappings(data.fieldMappings || []);
setBatchSize(data.options?.batchSize?.toString() || "");
setIgnoreErrors(data.options?.ignoreErrors || false);
setIgnoreDuplicates(data.options?.ignoreDuplicates || false);
}, [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) => {
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 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;
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" />
<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>
</div>
</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">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{columnsLoading && (
<div className="rounded border p-3 text-center text-xs text-gray-500"> ...</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-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>
{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 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>
);
}