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

263 lines
9.6 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";
/**
*
*/
import { useEffect, useState } from "react";
import { Check, ChevronsUpDown } 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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen";
import type { TableSourceNodeData } from "@/types/node-editor";
interface TableSourcePropertiesProps {
nodeId: string;
data: TableSourceNodeData;
}
interface TableOption {
tableName: string;
displayName: string;
description: string;
label: string; // 표시용 (라벨 또는 테이블명)
}
export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesProps) {
const { updateNode } = useFlowEditorStore();
const [displayName, setDisplayName] = useState(data.displayName || data.tableName);
const [tableName, setTableName] = useState(data.tableName);
// 테이블 선택 관련 상태
const [tables, setTables] = useState<TableOption[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || data.tableName);
setTableName(data.tableName);
}, [data.displayName, data.tableName]);
// 테이블 목록 로딩
useEffect(() => {
loadTables();
}, []);
/**
*
*/
const loadTables = async () => {
try {
setLoading(true);
console.log("🔍 테이블 목록 로딩 중...");
const tableList = await tableTypeApi.getTables();
// 테이블 목록 변환 (라벨 또는 displayName 우선 표시)
const options: TableOption[] = tableList.map((table) => {
// tableLabel이 있으면 우선 사용, 없으면 displayName, 그것도 없으면 tableName
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 {
setLoading(false);
}
};
/**
* ( + )
*/
const handleTableSelect = async (selectedTableName: string) => {
const selectedTable = tables.find((t) => t.tableName === selectedTableName);
if (selectedTable) {
const newTableName = selectedTable.tableName;
const newDisplayName = selectedTable.label;
setTableName(newTableName);
setDisplayName(newDisplayName);
setOpen(false);
// 컬럼 정보 로드
console.log(`🔍 테이블 "${newTableName}" 컬럼 로드 중...`);
try {
const columns = await tableTypeApi.getColumns(newTableName);
console.log("🔍 API에서 받은 컬럼 데이터:", columns);
const fields = columns.map((col: any) => ({
name: col.column_name || col.columnName,
type: col.data_type || col.dataType || "unknown",
nullable: col.is_nullable === "YES" || col.isNullable === true,
// displayName이 라벨입니다!
label: col.displayName || col.label_ko || col.columnLabel || col.column_label,
}));
console.log(`${fields.length}개 컬럼 로드 완료:`, fields);
// 필드 정보와 함께 노드 업데이트
updateNode(nodeId, {
displayName: newDisplayName,
tableName: newTableName,
fields,
});
} catch (error) {
console.error("❌ 컬럼 로드 실패:", error);
// 실패해도 테이블 정보는 업데이트
updateNode(nodeId, {
displayName: newDisplayName,
tableName: newTableName,
fields: [],
});
}
console.log(`✅ 테이블 선택: ${newTableName} (${newDisplayName})`);
}
};
/**
*
*/
const handleDisplayNameChange = (newDisplayName: string) => {
setDisplayName(newDisplayName);
updateNode(nodeId, {
displayName: newDisplayName,
tableName,
});
};
// 현재 선택된 테이블의 라벨 찾기
const selectedTableLabel = tables.find((t) => t.tableName === tableName)?.label || tableName;
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) => handleDisplayNameChange(e.target.value)}
className="mt-1"
placeholder="노드 표시 이름"
/>
</div>
{/* 테이블 선택 Combobox */}
<div>
<Label className="text-xs"> </Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="mt-1 w-full justify-between"
disabled={loading}
>
{loading ? (
<span className="text-muted-foreground"> ...</span>
) : tableName ? (
<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",
tableName === 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>
{tableName && selectedTableLabel !== tableName && (
<p className="text-muted-foreground mt-1 text-xs">
: <code className="rounded bg-gray-100 px-1 py-0.5">{tableName}</code>
</p>
)}
</div>
</div>
</div>
{/* 필드 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
{data.fields && data.fields.length > 0 ? (
<div className="space-y-1 rounded border p-2">
{data.fields.map((field) => (
<div key={field.name} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-700">{field.name}</span>
<span className="text-gray-400">{field.type}</span>
</div>
))}
</div>
) : (
<div className="rounded border p-4 text-center text-xs text-gray-400"> </div>
)}
</div>
{/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700"> .</div>
</div>
</ScrollArea>
);
}