diff --git a/backend-node/src/types/unified-web-types.ts b/backend-node/src/types/unified-web-types.ts index 52f953ac..9ac51e57 100644 --- a/backend-node/src/types/unified-web-types.ts +++ b/backend-node/src/types/unified-web-types.ts @@ -112,9 +112,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record = { json: "textarea", jsonb: "textarea", - // 배열 타입 (텍스트로 처리) - ARRAY: "textarea", - // UUID 타입 uuid: "text", }; diff --git a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx b/frontend/app/(main)/admin/dataflow/node-editor/page.tsx index a8ba0d66..9e1cfab6 100644 --- a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx +++ b/frontend/app/(main)/admin/dataflow/node-editor/page.tsx @@ -1,26 +1,24 @@ "use client"; /** - * 제어 시스템 페이지 + * 제어 시스템 페이지 (리다이렉트) + * 이 페이지는 /admin/dataflow로 리다이렉트됩니다. */ -import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; export default function NodeEditorPage() { - return ( -
- {/* 페이지 헤더 */} -
-
-

제어 시스템

-

- 드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다 -

-
-
+ const router = useRouter(); - {/* 에디터 */} - + useEffect(() => { + // /admin/dataflow 메인 페이지로 리다이렉트 + router.replace("/admin/dataflow"); + }, [router]); + + return ( +
+
제어 관리 페이지로 이동중...
); } diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx index a5f25a97..ff7e5aeb 100644 --- a/frontend/app/(main)/admin/dataflow/page.tsx +++ b/frontend/app/(main)/admin/dataflow/page.tsx @@ -2,102 +2,78 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner"; import DataFlowList from "@/components/dataflow/DataFlowList"; -// 🎨 새로운 UI 컴포넌트 import -import DataConnectionDesigner from "@/components/dataflow/connection/redesigned/DataConnectionDesigner"; -import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow"; +import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; import { useAuth } from "@/hooks/useAuth"; -import { loadDataflowRelationship } from "@/lib/api/dataflowSave"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; -type Step = "list" | "design"; +type Step = "list" | "editor"; export default function DataFlowPage() { const { user } = useAuth(); const router = useRouter(); const [currentStep, setCurrentStep] = useState("list"); - const [stepHistory, setStepHistory] = useState(["list"]); - const [editingDiagram, setEditingDiagram] = useState(null); - const [loadedRelationshipData, setLoadedRelationshipData] = useState(null); + const [loadingFlowId, setLoadingFlowId] = useState(null); - // 단계별 제목과 설명 - const stepConfig = { - list: { - title: "데이터 흐름 제어 관리", - description: "생성된 제어들을 확인하고 관리하세요", - icon: "📊", - }, - design: { - title: "새 제어 설계", - description: "테이블 간 데이터 제어를 시각적으로 설계하세요", - icon: "🎨", - }, - }; + // 플로우 불러오기 핸들러 + const handleLoadFlow = async (flowId: number | null) => { + if (flowId === null) { + // 새 플로우 생성 + setLoadingFlowId(null); + setCurrentStep("editor"); + return; + } - // 다음 단계로 이동 - const goToNextStep = (nextStep: Step) => { - setStepHistory((prev) => [...prev, nextStep]); - setCurrentStep(nextStep); - }; + try { + // 기존 플로우 불러오기 + setLoadingFlowId(flowId); + setCurrentStep("editor"); - // 이전 단계로 이동 - const goToPreviousStep = () => { - if (stepHistory.length > 1) { - const newHistory = stepHistory.slice(0, -1); - const previousStep = newHistory[newHistory.length - 1]; - setStepHistory(newHistory); - setCurrentStep(previousStep); + toast.success("플로우를 불러왔습니다."); + } catch (error: any) { + console.error("❌ 플로우 불러오기 실패:", error); + toast.error(error.message || "플로우를 불러오는데 실패했습니다."); } }; - // 특정 단계로 이동 - const goToStep = (step: Step) => { - setCurrentStep(step); - // 해당 단계까지의 히스토리만 유지 - const stepIndex = stepHistory.findIndex((s) => s === step); - if (stepIndex !== -1) { - setStepHistory(stepHistory.slice(0, stepIndex + 1)); - } + // 목록으로 돌아가기 + const handleBackToList = () => { + setCurrentStep("list"); + setLoadingFlowId(null); }; - const handleSave = (relationships: TableRelationship[]) => { - console.log("저장된 제어:", relationships); - // 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연 - setTimeout(() => { - goToStep("list"); - setEditingDiagram(null); - setLoadedRelationshipData(null); - }, 0); - }; + // 에디터 모드일 때는 전체 화면 사용 + const isEditorMode = currentStep === "editor"; - // 제어 수정 핸들러 - const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => { - if (diagram) { - // 기존 제어 수정 - 저장된 제어 정보 로드 - try { - console.log("📖 제어 수정 모드:", diagram); + // 에디터 모드일 때는 레이아웃 없이 전체 화면 사용 + if (isEditorMode) { + return ( +
+
+ {/* 에디터 헤더 */} +
+ +
+

노드 플로우 에디터

+

+ 드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다 +

+
+
- // 저장된 제어 정보 로드 - const relationshipData = await loadDataflowRelationship(diagram.diagramId); - console.log("✅ 제어 정보 로드 완료:", relationshipData); - - setEditingDiagram(diagram); - setLoadedRelationshipData(relationshipData); - goToNextStep("design"); - - toast.success(`"${diagram.diagramName}" 제어를 불러왔습니다.`); - } catch (error: any) { - console.error("❌ 제어 정보 로드 실패:", error); - toast.error(error.message || "제어 정보를 불러오는데 실패했습니다."); - } - } else { - // 새 제어 생성 - 현재 페이지에서 처리 - setEditingDiagram(null); - setLoadedRelationshipData(null); - goToNextStep("design"); - } - }; + {/* 플로우 에디터 */} +
+ +
+
+
+ ); + } return (
@@ -106,32 +82,12 @@ export default function DataFlowPage() {

제어 관리

-

테이블 간 데이터 제어를 시각적으로 설계하고 관리합니다

+

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

- {/* 단계별 내용 */} -
- {/* 제어 목록 단계 */} - {currentStep === "list" && } - - {/* 제어 설계 단계 - 🎨 새로운 UI 사용 */} - {currentStep === "design" && ( - { - goToStep("list"); - setEditingDiagram(null); - setLoadedRelationshipData(null); - }} - initialData={ - loadedRelationshipData || { - connectionType: "data_save", - } - } - showBackButton={true} - /> - )} -
+ {/* 플로우 목록 */} +
); diff --git a/frontend/components/dataflow/DataFlowList.tsx b/frontend/components/dataflow/DataFlowList.tsx index a49fa08f..00b0e567 100644 --- a/frontend/components/dataflow/DataFlowList.tsx +++ b/frontend/components/dataflow/DataFlowList.tsx @@ -20,158 +20,129 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react"; -import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; +import { apiClient } from "@/lib/api/client"; -interface DataFlowListProps { - onDesignDiagram: (diagram: DataFlowDiagram | null) => void; +// 노드 플로우 타입 정의 +interface NodeFlow { + flowId: number; + flowName: string; + flowDescription: string; + createdAt: string; + updatedAt: string; } -export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) { +interface DataFlowListProps { + onLoadFlow: (flowId: number | null) => void; +} + +export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { const { user } = useAuth(); - const [diagrams, setDiagrams] = useState([]); + const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [total, setTotal] = useState(0); - - // 사용자 회사 코드 가져오기 (기본값: "*") - const companyCode = user?.company_code || user?.companyCode || "*"; // 모달 상태 const [showCopyModal, setShowCopyModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); - const [selectedDiagramForAction, setSelectedDiagramForAction] = useState(null); + const [selectedFlow, setSelectedFlow] = useState(null); - // 목록 로드 함수 분리 - const loadDiagrams = useCallback(async () => { + // 노드 플로우 목록 로드 + const loadFlows = useCallback(async () => { try { setLoading(true); - const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode); + const response = await apiClient.get("/dataflow/node-flows"); - // JSON API 응답을 기존 형식으로 변환 - const convertedDiagrams = response.diagrams.map((diagram) => { - // relationships 구조 분석 - const relationships = diagram.relationships || {}; - - // 테이블 정보 추출 - const tables: string[] = []; - if (relationships.fromTable?.tableName) { - tables.push(relationships.fromTable.tableName); - } - if ( - relationships.toTable?.tableName && - relationships.toTable.tableName !== relationships.fromTable?.tableName - ) { - tables.push(relationships.toTable.tableName); - } - - // 제어 수 계산 (actionGroups 기준) - const actionGroups = relationships.actionGroups || []; - const relationshipCount = actionGroups.reduce((count: number, group: any) => { - return count + (group.actions?.length || 0); - }, 0); - - return { - diagramId: diagram.diagram_id, - relationshipId: diagram.diagram_id, // 호환성을 위해 추가 - diagramName: diagram.diagram_name, - connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용 - relationshipType: "multi-relationship", // 다중 제어 타입 - relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정 - tableCount: tables.length, - tables: tables, - companyCode: diagram.company_code, // 회사 코드 추가 - createdAt: new Date(diagram.created_at || new Date()), - createdBy: diagram.created_by || "SYSTEM", - updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()), - updatedBy: diagram.updated_by || "SYSTEM", - lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(), - }; - }); - - setDiagrams(convertedDiagrams); - setTotal(response.pagination.total || 0); - setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20))); + if (response.data.success) { + setFlows(response.data.data); + } else { + throw new Error(response.data.message || "플로우 목록 조회 실패"); + } } catch (error) { - console.error("제어 목록 조회 실패", error); - toast.error("제어 목록을 불러오는데 실패했습니다."); + console.error("플로우 목록 조회 실패", error); + toast.error("플로우 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } - }, [currentPage, searchTerm, companyCode]); + }, []); - // 제어 목록 로드 + // 플로우 목록 로드 useEffect(() => { - loadDiagrams(); - }, [loadDiagrams]); + loadFlows(); + }, [loadFlows]); - const handleDelete = (diagram: DataFlowDiagram) => { - setSelectedDiagramForAction(diagram); + // 플로우 삭제 + const handleDelete = (flow: NodeFlow) => { + setSelectedFlow(flow); setShowDeleteModal(true); }; - const handleCopy = (diagram: DataFlowDiagram) => { - setSelectedDiagramForAction(diagram); - setShowCopyModal(true); - }; - - // 복사 확인 - const handleConfirmCopy = async () => { - if (!selectedDiagramForAction) return; - + // 플로우 복사 + const handleCopy = async (flow: NodeFlow) => { try { setLoading(true); - const copiedDiagram = await DataFlowAPI.copyJsonDataFlowDiagram( - selectedDiagramForAction.diagramId, - companyCode, - undefined, - user?.userId || "SYSTEM", - ); - toast.success(`제어가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`); - // 목록 새로고침 - await loadDiagrams(); + // 원본 플로우 데이터 가져오기 + const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`); + + if (!response.data.success) { + throw new Error(response.data.message || "플로우 조회 실패"); + } + + const originalFlow = response.data.data; + + // 복사본 저장 + const copyResponse = await apiClient.post("/dataflow/node-flows", { + flowName: `${flow.flowName} (복사본)`, + flowDescription: flow.flowDescription, + flowData: originalFlow.flowData, + }); + + if (copyResponse.data.success) { + toast.success(`플로우가 성공적으로 복사되었습니다`); + await loadFlows(); + } else { + throw new Error(copyResponse.data.message || "플로우 복사 실패"); + } } catch (error) { - console.error("제어 복사 실패:", error); - toast.error("제어 복사에 실패했습니다."); + console.error("플로우 복사 실패:", error); + toast.error("플로우 복사에 실패했습니다."); } finally { setLoading(false); - setShowCopyModal(false); - setSelectedDiagramForAction(null); } }; // 삭제 확인 const handleConfirmDelete = async () => { - if (!selectedDiagramForAction) return; + if (!selectedFlow) return; try { setLoading(true); - await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode); - toast.success(`제어가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`); + const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`); - // 목록 새로고침 - await loadDiagrams(); + if (response.data.success) { + toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`); + await loadFlows(); + } else { + throw new Error(response.data.message || "플로우 삭제 실패"); + } } catch (error) { - console.error("제어 삭제 실패:", error); - toast.error("제어 삭제에 실패했습니다."); + console.error("플로우 삭제 실패:", error); + toast.error("플로우 삭제에 실패했습니다."); } finally { setLoading(false); setShowDeleteModal(false); - setSelectedDiagramForAction(null); + setSelectedFlow(null); } }; - if (loading) { - return ( -
-
로딩 중...
-
- ); - } + // 검색 필터링 + const filteredFlows = flows.filter( + (flow) => + flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) || + flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()), + ); return (
@@ -181,173 +152,125 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
setSearchTerm(e.target.value)} className="w-80 pl-10" />
- - {/* 제어 목록 테이블 */} + {/* 플로우 목록 테이블 */} - 데이터 흐름 제어 ({total}) + 노드 플로우 목록 ({filteredFlows.length}) - - - - 제어명 - 회사 코드 - 테이블 수 - 액션 수 - 최근 수정 - 작업 - - - - {diagrams.map((diagram) => ( - - -
-
- - {diagram.diagramName} -
-
- 테이블: {diagram.tables.slice(0, 3).join(", ")} - {diagram.tables.length > 3 && ` 외 ${diagram.tables.length - 3}개`} -
-
-
- {diagram.companyCode || "*"} - -
- - {diagram.tableCount} -
-
- -
- - {diagram.relationshipCount} -
-
- -
- - {new Date(diagram.updatedAt).toLocaleDateString()} -
-
- - {diagram.updatedBy} -
-
- - - - - - - onDesignDiagram(diagram)}> - - 수정 - - handleCopy(diagram)}> - - 복사 - - handleDelete(diagram)} className="text-destructive"> - - 삭제 - - - - -
- ))} -
-
- - {diagrams.length === 0 && ( -
- -
제어가 없습니다
-
새 제어를 생성하여 테이블 간 데이터 제어를 설정해보세요.
+ {loading ? ( +
+
로딩 중...
+ ) : ( + <> + + + + 플로우명 + 설명 + 생성일 + 최근 수정 + 작업 + + + + {filteredFlows.map((flow) => ( + onLoadFlow(flow.flowId)} + > + +
+ + {flow.flowName} +
+
+ +
{flow.flowDescription || "설명 없음"}
+
+ +
+ + {new Date(flow.createdAt).toLocaleDateString()} +
+
+ +
+ + {new Date(flow.updatedAt).toLocaleDateString()} +
+
+ e.stopPropagation()}> +
+ + + + + + onLoadFlow(flow.flowId)}> + + 불러오기 + + handleCopy(flow)}> + + 복사 + + handleDelete(flow)} className="text-destructive"> + + 삭제 + + + +
+
+
+ ))} +
+
+ + {filteredFlows.length === 0 && ( +
+ +
플로우가 없습니다
+
새 플로우를 생성하여 노드 기반 데이터 제어를 설계해보세요.
+
+ )} + )} - {/* 페이지네이션 */} - {totalPages > 1 && ( -
- - - {currentPage} / {totalPages} - - -
- )} - - {/* 복사 확인 모달 */} - - - - 제어 복사 - - “{selectedDiagramForAction?.diagramName}” 제어를 복사하시겠습니까? -
- 새로운 제어는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다. -
-
- - - - -
-
- {/* 삭제 확인 모달 */} - 제어 삭제 + 플로우 삭제 - “{selectedDiagramForAction?.diagramName}” 제어를 완전히 삭제하시겠습니까? + “{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
- 이 작업은 되돌릴 수 없으며, 모든 제어 정보가 영구적으로 삭제됩니다. + 이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 279f64d4..4a5c0903 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -4,12 +4,15 @@ * 노드 기반 플로우 에디터 메인 컴포넌트 */ -import { useCallback, useRef } from "react"; +import { useCallback, useRef, useEffect, useState } from "react"; import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow"; import "reactflow/dist/style.css"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { apiClient } from "@/lib/api/client"; import { NodePalette } from "./sidebar/NodePalette"; +import { LeftUnifiedToolbar, ToolbarButton } from "@/components/screen/toolbar/LeftUnifiedToolbar"; +import { Boxes, Settings } from "lucide-react"; import { PropertiesPanel } from "./panels/PropertiesPanel"; import { FlowToolbar } from "./FlowToolbar"; import { TableSourceNode } from "./nodes/TableSourceNode"; @@ -48,10 +51,38 @@ const nodeTypes = { /** * FlowEditor 내부 컴포넌트 */ -function FlowEditorInner() { +interface FlowEditorInnerProps { + initialFlowId?: number | null; +} + +// 플로우 에디터 툴바 버튼 설정 +const flowToolbarButtons: ToolbarButton[] = [ + { + id: "nodes", + label: "노드", + icon: , + shortcut: "N", + group: "source", + panelWidth: 300, + }, + { + id: "properties", + label: "속성", + icon: , + shortcut: "P", + group: "editor", + panelWidth: 350, + }, +]; + +function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { const reactFlowWrapper = useRef(null); const { screenToFlowPosition } = useReactFlow(); + // 패널 표시 상태 + const [showNodesPanel, setShowNodesPanel] = useState(true); + const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false); + const { nodes, edges, @@ -61,13 +92,50 @@ function FlowEditorInner() { onNodeDragStart, addNode, showPropertiesPanel, + setShowPropertiesPanel, selectNodes, selectedNodes, removeNodes, undo, redo, + loadFlow, } = useFlowEditorStore(); + // 속성 패널 상태 동기화 + useEffect(() => { + if (selectedNodes.length > 0 && !showPropertiesPanelLocal) { + setShowPropertiesPanelLocal(true); + } + }, [selectedNodes, showPropertiesPanelLocal]); + + // 초기 플로우 로드 + useEffect(() => { + const fetchAndLoadFlow = async () => { + if (initialFlowId) { + try { + const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`); + + if (response.data.success && response.data.data) { + const flow = response.data.data; + const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData; + + loadFlow( + flow.flowId, + flow.flowName, + flow.flowDescription || "", + flowData.nodes || [], + flowData.edges || [], + ); + } + } catch (error) { + console.error("플로우 로드 실패:", error); + } + } + }; + + fetchAndLoadFlow(); + }, [initialFlowId]); + /** * 노드 선택 변경 핸들러 */ @@ -178,10 +246,29 @@ function FlowEditorInner() { return (
- {/* 좌측 노드 팔레트 */} -
- -
+ {/* 좌측 통합 툴바 */} + { + if (panelId === "nodes") { + setShowNodesPanel(!showNodesPanel); + } else if (panelId === "properties") { + setShowPropertiesPanelLocal(!showPropertiesPanelLocal); + setShowPropertiesPanel(!showPropertiesPanelLocal); + } + }} + /> + + {/* 노드 라이브러리 패널 */} + {showNodesPanel && ( +
+ +
+ )} {/* 중앙 캔버스 */}
@@ -224,8 +311,8 @@ function FlowEditorInner() {
{/* 우측 속성 패널 */} - {showPropertiesPanel && ( -
+ {showPropertiesPanelLocal && selectedNodes.length > 0 && ( +
)} @@ -236,11 +323,15 @@ function FlowEditorInner() { /** * FlowEditor 메인 컴포넌트 (Provider로 감싸기) */ -export function FlowEditor() { +interface FlowEditorProps { + initialFlowId?: number | null; +} + +export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) { return ( -
+
- +
); diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index 7bcb9443..36d60e98 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -4,14 +4,11 @@ * 플로우 에디터 상단 툴바 */ -import { useState } from "react"; -import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react"; +import { Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useReactFlow } from "reactflow"; -import { LoadFlowDialog } from "./dialogs/LoadFlowDialog"; -import { getNodeFlow } from "@/lib/api/nodeFlows"; export function FlowToolbar() { const { zoomIn, zoomOut, fitView } = useReactFlow(); @@ -21,7 +18,6 @@ export function FlowToolbar() { validateFlow, saveFlow, exportFlow, - isExecuting, isSaving, selectedNodes, removeNodes, @@ -30,7 +26,6 @@ export function FlowToolbar() { canUndo, canRedo, } = useFlowEditorStore(); - const [showLoadDialog, setShowLoadDialog] = useState(false); const handleValidate = () => { const result = validateFlow(); @@ -62,29 +57,6 @@ export function FlowToolbar() { alert("✅ JSON 파일로 내보내기 완료!"); }; - const handleLoad = async (flowId: number) => { - try { - const flow = await getNodeFlow(flowId); - - // flowData가 이미 객체인지 문자열인지 확인 - const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData; - - // Zustand 스토어의 loadFlow 함수 호출 - useFlowEditorStore - .getState() - .loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges); - alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`); - } catch (error) { - console.error("플로우 불러오기 오류:", error); - alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다."); - } - }; - - const handleExecute = () => { - // TODO: 실행 로직 구현 - alert("실행 기능 구현 예정"); - }; - const handleDelete = () => { if (selectedNodes.length === 0) { alert("삭제할 노드를 선택해주세요."); @@ -98,94 +70,74 @@ export function FlowToolbar() { }; return ( - <> - -
- {/* 플로우 이름 */} - setFlowName(e.target.value)} - className="h-8 w-[200px] text-sm" - placeholder="플로우 이름" - /> +
+ {/* 플로우 이름 */} + setFlowName(e.target.value)} + className="h-8 w-[200px] text-sm" + placeholder="플로우 이름" + /> -
+
- {/* 실행 취소/다시 실행 */} - - + {/* 실행 취소/다시 실행 */} + + -
+
- {/* 삭제 버튼 */} - + {/* 삭제 버튼 */} + -
+
- {/* 줌 컨트롤 */} - - - + {/* 줌 컨트롤 */} + + + -
+
- {/* 불러오기 */} - + {/* 저장 */} + - {/* 저장 */} - + {/* 내보내기 */} + - {/* 내보내기 */} - +
-
- - {/* 검증 */} - - - {/* 테스트 실행 */} - -
- + {/* 검증 */} + +
); } diff --git a/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx b/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx index c28dfc55..0167069d 100644 --- a/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx +++ b/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx @@ -10,7 +10,9 @@ import { NODE_CATEGORIES, getNodesByCategory } from "./nodePaletteConfig"; import type { NodePaletteItem } from "@/types/node-editor"; export function NodePalette() { - const [expandedCategories, setExpandedCategories] = useState>(new Set(["source", "transform", "action"])); + const [expandedCategories, setExpandedCategories] = useState>( + new Set(["source", "transform", "action", "utility"]), + ); const toggleCategory = (categoryId: string) => { setExpandedCategories((prev) => { @@ -25,7 +27,7 @@ export function NodePalette() { }; return ( -
+
{/* 헤더 */}

노드 라이브러리

@@ -46,7 +48,6 @@ export function NodePalette() { className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100" > {isExpanded ? : } - {category.icon} {category.label} {nodes.length} @@ -89,13 +90,8 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) { title={node.description} >
- {/* 아이콘 */} -
- {node.icon} -
+ {/* 색상 인디케이터 (좌측) */} +
{/* 라벨 및 설명 */}
@@ -104,7 +100,7 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
- {/* 색상 인디케이터 */} + {/* 하단 색상 인디케이터 (hover 시) */}
{ - if (selectedScreen?.tableName && selectedScreen.tableName.trim()) { - const loadTable = async () => { - try { - // 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화) - const [columnsResponse, tableLabelResponse] = await Promise.all([ - tableTypeApi.getColumns(selectedScreen.tableName), - tableTypeApi.getTableLabel(selectedScreen.tableName), - ]); + const loadScreenTable = async () => { + const tableName = selectedScreen?.tableName; + if (!tableName) { + setTables([]); + return; + } - const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({ - tableName: col.tableName || selectedScreen.tableName, - columnName: col.columnName || col.column_name, - // 우선순위: displayName(라벨) > columnLabel > column_label > columnName > column_name - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - dataType: col.dataType || col.data_type || col.dbType, - webType: col.webType || col.web_type, - input_type: col.inputType || col.input_type, // 🎯 input_type 필드 추가 - widgetType: col.widgetType || col.widget_type || col.webType || col.web_type, - isNullable: col.isNullable || col.is_nullable, - required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", - columnDefault: col.columnDefault || col.column_default, - characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, - // 코드 카테고리 정보 추가 - codeCategory: col.codeCategory || col.code_category, - codeValue: col.codeValue || col.code_value, - })); + try { + // 테이블 라벨 조회 + const tableListResponse = await tableManagementApi.getTableList(); + const currentTable = + tableListResponse.success && tableListResponse.data + ? tableListResponse.data.find((t) => t.tableName === tableName) + : null; + const tableLabel = currentTable?.displayName || tableName; - const tableInfo: TableInfo = { - tableName: selectedScreen.tableName, - // 테이블 라벨이 있으면 우선 표시, 없으면 테이블명 그대로 - tableLabel: tableLabelResponse.tableLabel || selectedScreen.tableName, - columns: columns, - }; - setTables([tableInfo]); // 단일 테이블 정보만 설정 - } catch (error) { - // console.error("테이블 정보 로드 실패:", error); - toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`); - } - }; + // 현재 화면의 테이블 컬럼 정보 조회 + const columnsResponse = await tableTypeApi.getColumns(tableName); - loadTable(); - } else { - // 테이블명이 없는 경우 테이블 목록 초기화 - setTables([]); - } - }, [selectedScreen?.tableName]); + const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({ + tableName: col.tableName || tableName, + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + webType: col.webType || col.web_type, + input_type: col.inputType || col.input_type, + widgetType: col.widgetType || col.widget_type || col.webType || col.web_type, + isNullable: col.isNullable || col.is_nullable, + required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", + columnDefault: col.columnDefault || col.column_default, + characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, + codeCategory: col.codeCategory || col.code_category, + codeValue: col.codeValue || col.code_value, + })); + + const tableInfo: TableInfo = { + tableName, + tableLabel, + columns, + }; + + setTables([tableInfo]); + } catch (error) { + console.error("화면 테이블 정보 로드 실패:", error); + setTables([]); + } + }; + + loadScreenTable(); + }, [selectedScreen?.tableName, selectedScreen?.screenName]); // 화면 레이아웃 로드 useEffect(() => { @@ -2044,6 +2049,30 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD gridColumns, }); + // 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가 + let enhancedDefaultConfig = { ...component.defaultConfig }; + if ( + component.id === "repeater-field-group" && + tables && + tables.length > 0 && + tables[0].columns && + tables[0].columns.length > 0 + ) { + const firstColumn = tables[0].columns[0]; + enhancedDefaultConfig = { + ...enhancedDefaultConfig, + fields: [ + { + name: firstColumn.columnName, + label: firstColumn.columnLabel || firstColumn.columnName, + type: (firstColumn.widgetType as any) || "text", + required: firstColumn.required || false, + placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`, + }, + ], + }; + } + const newComponent: ComponentData = { id: generateComponentId(), type: "component", // ✅ 새 컴포넌트 시스템 사용 @@ -2056,7 +2085,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 - ...component.defaultConfig, + ...enhancedDefaultConfig, }, webTypeConfig: getDefaultWebTypeConfig(component.webType), style: { diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 746a2218..b8e6b2ca 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -362,20 +362,24 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const handleView = async (screen: ScreenDefinition) => { setScreenToPreview(screen); - setPreviewDialogOpen(true); + setPreviewLayout(null); // 이전 레이아웃 초기화 setIsLoadingPreview(true); + setPreviewDialogOpen(true); // 모달 먼저 열기 - try { - // 화면 레이아웃 로드 - const layoutData = await screenApi.getLayout(screen.screenId); - console.log("📊 미리보기 레이아웃 로드:", layoutData); - setPreviewLayout(layoutData); - } catch (error) { - console.error("❌ 레이아웃 로드 실패:", error); - toast.error("화면 레이아웃을 불러오는데 실패했습니다."); - } finally { - setIsLoadingPreview(false); - } + // 모달이 열린 후에 레이아웃 로드 + setTimeout(async () => { + try { + // 화면 레이아웃 로드 + const layoutData = await screenApi.getLayout(screen.screenId); + console.log("📊 미리보기 레이아웃 로드:", layoutData); + setPreviewLayout(layoutData); + } catch (error) { + console.error("❌ 레이아웃 로드 실패:", error); + toast.error("화면 레이아웃을 불러오는데 실패했습니다."); + } finally { + setIsLoadingPreview(false); + } + }, 100); // 100ms 딜레이로 모달 애니메이션이 먼저 시작되도록 }; const handleCopySuccess = () => { @@ -877,7 +881,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr {/* 화면 미리보기 다이얼로그 */} - + 화면 미리보기 - {screenToPreview?.screenName} diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 5ec0b646..5aca236a 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -37,8 +37,13 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) { // 카테고리별 컴포넌트 그룹화 const componentsByCategory = useMemo(() => { + // 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들) + const hiddenInputComponents = ["text-input", "number-input", "date-input", "textarea-basic"]; + return { - input: allComponents.filter((c) => c.category === ComponentCategory.INPUT && c.id === "file-upload"), + input: allComponents.filter( + (c) => c.category === ComponentCategory.INPUT && !hiddenInputComponents.includes(c.id), + ), action: allComponents.filter((c) => c.category === ComponentCategory.ACTION), display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY), layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT), diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 91180aaa..c627a311 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -37,6 +37,7 @@ interface DetailSettingsPanelProps { onUpdateProperty: (componentId: string, path: string, value: any) => void; currentTable?: TableInfo; // 현재 화면의 테이블 정보 currentTableName?: string; // 현재 화면의 테이블명 + tables?: TableInfo[]; // 전체 테이블 목록 } export const DetailSettingsPanel: React.FC = ({ @@ -44,6 +45,7 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty, currentTable, currentTableName, + tables = [], // 기본값 빈 배열 }) => { // 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기 const { webTypes } = useWebTypes({ active: "Y" }); @@ -1104,6 +1106,7 @@ export const DetailSettingsPanel: React.FC = ({ // }); return currentTable?.columns || []; })()} + tables={tables} // 전체 테이블 목록 전달 onChange={(newConfig) => { // console.log("🔧 컴포넌트 설정 변경:", newConfig); // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index e7877be6..b04dd0a4 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -487,6 +487,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type; const webType = selectedComponent.componentConfig?.webType; + // 테이블 패널에서 드래그한 컴포넌트인지 확인 + const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName); + if (!componentId) { return (
@@ -509,18 +512,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ return (
- {/* 컴포넌트 정보 */} -
- 컴포넌트: {componentId} - {webType && currentBaseInputType && ( -
입력 타입: {currentBaseInputType}
- )} -
- - {/* 세부 타입 선택 */} - {webType && availableDetailTypes.length > 1 && ( + {/* 세부 타입 선택 - 테이블 패널에서 드래그한 컴포넌트만 표시 */} + {isFromTablePanel && webType && availableDetailTypes.length > 1 && (
- + -

입력 타입 "{currentBaseInputType}"의 세부 형태를 선택하세요

)} diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 15d5afd3..4256e329 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< void; + config?: RepeaterFieldGroupConfig; + disabled?: boolean; + readonly?: boolean; + className?: string; +} + +/** + * 반복 필드 그룹 컴포넌트 + * 여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 컴포넌트 + */ +export const RepeaterInput: React.FC = ({ + value = [], + onChange, + config = { fields: [] }, + disabled = false, + readonly = false, + className, +}) => { + // 설정 기본값 + const { + fields = [], + minItems = 0, + maxItems = 10, + addButtonText = "항목 추가", + allowReorder = true, + showIndex = true, + collapsible = false, + layout = "grid", // 기본값을 grid로 설정 + showDivider = true, + emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.", + } = config; + + // 로컬 상태 관리 + const [items, setItems] = useState( + value.length > 0 + ? value + : minItems > 0 + ? Array(minItems) + .fill(null) + .map(() => createEmptyItem()) + : [], + ); + + // 접힌 상태 관리 (각 항목별) + const [collapsedItems, setCollapsedItems] = useState>(new Set()); + + // 빈 항목 생성 + function createEmptyItem(): RepeaterItemData { + const item: RepeaterItemData = {}; + fields.forEach((field) => { + item[field.name] = ""; + }); + return item; + } + + // 외부 value 변경 시 동기화 + useEffect(() => { + if (value.length > 0) { + setItems(value); + } + }, [value]); + + // 항목 추가 + const handleAddItem = () => { + if (items.length >= maxItems) { + return; + } + const newItems = [...items, createEmptyItem()]; + setItems(newItems); + onChange?.(newItems); + }; + + // 항목 제거 + const handleRemoveItem = (index: number) => { + if (items.length <= minItems) { + return; + } + const newItems = items.filter((_, i) => i !== index); + setItems(newItems); + onChange?.(newItems); + + // 접힌 상태도 업데이트 + const newCollapsed = new Set(collapsedItems); + newCollapsed.delete(index); + setCollapsedItems(newCollapsed); + }; + + // 필드 값 변경 + const handleFieldChange = (itemIndex: number, fieldName: string, value: any) => { + const newItems = [...items]; + newItems[itemIndex] = { + ...newItems[itemIndex], + [fieldName]: value, + }; + setItems(newItems); + onChange?.(newItems); + }; + + // 접기/펼치기 토글 + const toggleCollapse = (index: number) => { + const newCollapsed = new Set(collapsedItems); + if (newCollapsed.has(index)) { + newCollapsed.delete(index); + } else { + newCollapsed.add(index); + } + setCollapsedItems(newCollapsed); + }; + + // 드래그 앤 드롭 (순서 변경) + const [draggedIndex, setDraggedIndex] = useState(null); + + const handleDragStart = (index: number) => { + if (!allowReorder || readonly || disabled) return; + setDraggedIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + if (!allowReorder || readonly || disabled) return; + e.preventDefault(); + }; + + const handleDrop = (e: React.DragEvent, targetIndex: number) => { + if (!allowReorder || readonly || disabled || draggedIndex === null) return; + e.preventDefault(); + + const newItems = [...items]; + const draggedItem = newItems[draggedIndex]; + newItems.splice(draggedIndex, 1); + newItems.splice(targetIndex, 0, draggedItem); + + setItems(newItems); + onChange?.(newItems); + setDraggedIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + }; + + // 개별 필드 렌더링 + const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { + const commonProps = { + value: value || "", + disabled: disabled || readonly, + placeholder: field.placeholder, + required: field.required, + }; + + switch (field.type) { + case "select": + return ( + + ); + + case "textarea": + return ( +