From 11b1743f6be1f368cbbf3efb74a8d995c1cb2f10 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 19 Sep 2025 14:55:53 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/external-connections/page.tsx | 87 +++++++++++++++++-- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/external-connections/page.tsx index 2b6b27a5..a4021493 100644 --- a/frontend/app/(main)/admin/external-connections/page.tsx +++ b/frontend/app/(main)/admin/external-connections/page.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from "react"; import { Plus, Search, Pencil, Trash2, Database } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -23,6 +23,7 @@ import { ExternalDbConnectionAPI, ExternalDbConnection, ExternalDbConnectionFilter, + ConnectionTestRequest, } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal"; @@ -56,6 +57,8 @@ export default function ExternalConnectionsPage() { const [supportedDbTypes, setSupportedDbTypes] = useState>([]); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [connectionToDelete, setConnectionToDelete] = useState(null); + const [testingConnections, setTestingConnections] = useState>(new Set()); + const [testResults, setTestResults] = useState>(new Map()); // 데이터 로딩 const loadConnections = async () => { @@ -160,6 +163,57 @@ export default function ExternalConnectionsPage() { setConnectionToDelete(null); }; + // 연결 테스트 + const handleTestConnection = async (connection: ExternalDbConnection) => { + if (!connection.id) return; + + setTestingConnections((prev) => new Set(prev).add(connection.id!)); + + try { + const testData: ConnectionTestRequest = { + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name, + username: connection.username, + password: connection.password, + connection_timeout: connection.connection_timeout, + ssl_enabled: connection.ssl_enabled, + }; + + const result = await ExternalDbConnectionAPI.testConnection(testData); + + setTestResults((prev) => new Map(prev).set(connection.id!, result.success)); + + if (result.success) { + toast({ + title: "연결 성공", + description: `${connection.connection_name} 연결이 성공했습니다.`, + }); + } else { + toast({ + title: "연결 실패", + description: `${connection.connection_name} 연결에 실패했습니다.`, + variant: "destructive", + }); + } + } catch (error) { + console.error("연결 테스트 오류:", error); + setTestResults((prev) => new Map(prev).set(connection.id!, false)); + toast({ + title: "연결 테스트 오류", + description: "연결 테스트 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setTestingConnections((prev) => { + const newSet = new Set(prev); + newSet.delete(connection.id!); + return newSet; + }); + } + }; + // 모달 저장 처리 const handleModalSave = () => { setIsModalOpen(false); @@ -264,6 +318,7 @@ export default function ExternalConnectionsPage() { 사용자 상태 생성일 + 연결 테스트 작업 @@ -271,14 +326,7 @@ export default function ExternalConnectionsPage() { {connections.map((connection) => ( -
-
{connection.connection_name}
- {connection.description && ( -
- {connection.description} -
- )} -
+
{connection.connection_name}
@@ -298,6 +346,27 @@ export default function ExternalConnectionsPage() { {connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"} + +
+ + {testResults.has(connection.id!) && ( + + {testResults.get(connection.id!) ? "성공" : "실패"} + + )} +
+
- - - - + + + + + + + + + + + + 연결 생성 완료 + + + {createdConnectionName} 연결이 생성되었습니다. +
+ + 생성된 연결은 데이터플로우 다이어그램에서 확인할 수 있습니다. + +
+
+ + 확인 + +
+
+ ); }; diff --git a/frontend/components/dataflow/DataFlowDesigner.tsx b/frontend/components/dataflow/DataFlowDesigner.tsx index f3a5372f..daf2b454 100644 --- a/frontend/components/dataflow/DataFlowDesigner.tsx +++ b/frontend/components/dataflow/DataFlowDesigner.tsx @@ -606,10 +606,10 @@ export const DataFlowDesigner: React.FC = ({ // 관계도 저장 함수 const handleSaveDiagram = useCallback( - async (diagramName: string) => { + async (diagramName: string): Promise<{ success: boolean; error?: string }> => { if (nodes.length === 0) { toast.error("저장할 테이블이 없습니다."); - return; + return { success: false, error: "저장할 테이블이 없습니다." }; } setIsSaving(true); @@ -704,12 +704,49 @@ export const DataFlowDesigner: React.FC = ({ setCurrentDiagramName(newDiagram.diagram_name); } - toast.success(`관계도 "${diagramName}"가 성공적으로 저장되었습니다.`); setHasUnsavedChanges(false); - setShowSaveModal(false); + // 성공 모달은 SaveDiagramModal에서 처리하므로 여기서는 toast 제거 + return { success: true }; } catch (error) { - console.error("관계도 저장 실패:", error); - toast.error("관계도 저장 중 오류가 발생했습니다."); + // 에러 메시지 분석 + let errorMessage = "관계도 저장 중 오류가 발생했습니다."; + let isDuplicateError = false; + + // Axios 에러 처리 + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as any; + if (axiosError.response?.status === 409) { + // 중복 이름 에러 (409 Conflict) + errorMessage = "중복된 이름입니다."; + isDuplicateError = true; + } else if (axiosError.response?.data?.message) { + // 백엔드에서 제공한 에러 메시지 사용 + if (axiosError.response.data.message.includes("중복된 이름입니다")) { + errorMessage = "중복된 이름입니다."; + isDuplicateError = true; + } else { + errorMessage = axiosError.response.data.message; + } + } + } else if (error instanceof Error) { + if ( + error.message.includes("중복") || + error.message.includes("duplicate") || + error.message.includes("already exists") + ) { + errorMessage = "중복된 이름입니다."; + isDuplicateError = true; + } else { + errorMessage = error.message; + } + } + + // 중복 에러가 아닌 경우만 콘솔에 로그 출력 + if (!isDuplicateError) { + console.error("관계도 저장 실패:", error); + } + + return { success: false, error: errorMessage }; } finally { setIsSaving(false); } diff --git a/frontend/components/dataflow/SaveDiagramModal.tsx b/frontend/components/dataflow/SaveDiagramModal.tsx index a2385efc..02ae7f99 100644 --- a/frontend/components/dataflow/SaveDiagramModal.tsx +++ b/frontend/components/dataflow/SaveDiagramModal.tsx @@ -2,17 +2,27 @@ import React, { useState, useEffect } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CheckCircle } from "lucide-react"; import { JsonRelationship } from "@/lib/api/dataflow"; interface SaveDiagramModalProps { isOpen: boolean; onClose: () => void; - onSave: (diagramName: string) => void; + onSave: (diagramName: string) => Promise<{ success: boolean; error?: string }>; relationships: JsonRelationship[]; defaultName?: string; isLoading?: boolean; @@ -28,13 +38,15 @@ const SaveDiagramModal: React.FC = ({ }) => { const [diagramName, setDiagramName] = useState(defaultName); const [nameError, setNameError] = useState(""); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [savedDiagramName, setSavedDiagramName] = useState(""); // defaultName이 변경될 때마다 diagramName 업데이트 useEffect(() => { setDiagramName(defaultName); }, [defaultName]); - const handleSave = () => { + const handleSave = async () => { const trimmedName = diagramName.trim(); if (!trimmedName) { @@ -53,7 +65,39 @@ const SaveDiagramModal: React.FC = ({ } setNameError(""); - onSave(trimmedName); + + try { + // 부모에게 저장 요청하고 결과 받기 + const result = await onSave(trimmedName); + + if (result.success) { + // 성공 시 성공 모달 표시 + setSavedDiagramName(trimmedName); + setShowSuccessModal(true); + } else { + // 실패 시 에러 메시지 표시 + if ( + result.error?.includes("중복된 이름입니다") || + result.error?.includes("중복") || + result.error?.includes("duplicate") || + result.error?.includes("already exists") + ) { + setNameError("중복된 이름입니다."); + } else { + setNameError(result.error || "저장 중 오류가 발생했습니다."); + } + } + } catch (error) { + // 중복 에러가 아닌 경우만 콘솔에 로그 출력 + const isDuplicateError = + error && typeof error === "object" && "response" in error && (error as any).response?.status === 409; + + if (!isDuplicateError) { + console.error("저장 오류:", error); + } + + setNameError("저장 중 오류가 발생했습니다."); + } }; const handleClose = () => { @@ -64,6 +108,12 @@ const SaveDiagramModal: React.FC = ({ } }; + const handleSuccessModalClose = () => { + setShowSuccessModal(false); + setSavedDiagramName(""); + handleClose(); // 원래 모달도 닫기 + }; + const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !isLoading) { handleSave(); @@ -76,136 +126,160 @@ const SaveDiagramModal: React.FC = ({ ).sort(); return ( - - - - 📊 관계도 저장 - + <> + + + + 📊 관계도 저장 + -
- {/* 관계도 이름 입력 */} -
- - { - setDiagramName(e.target.value); - if (nameError) setNameError(""); - }} - onKeyPress={handleKeyPress} - placeholder="예: 사용자-부서 관계도" - disabled={isLoading} - className={nameError ? "border-red-500 focus:border-red-500" : ""} - /> - {nameError &&

{nameError}

} -
+
+ {/* 관계도 이름 입력 */} +
+ + { + setDiagramName(e.target.value); + if (nameError) setNameError(""); + }} + onKeyPress={handleKeyPress} + placeholder="예: 사용자-부서 관계도" + disabled={isLoading} + className={nameError ? "border-red-500 focus:border-red-500" : ""} + /> + {nameError &&

{nameError}

} +
- {/* 관계 요약 정보 */} -
-
-
{relationships.length}
-
관계 수
-
-
-
{connectedTables.length}
-
연결된 테이블
-
-
-
- {relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)} + {/* 관계 요약 정보 */} +
+
+
{relationships.length}
+
관계 수
-
연결된 컬럼
-
-
- - {/* 연결된 테이블 목록 */} - {connectedTables.length > 0 && ( - - - 연결된 테이블 - - -
- {connectedTables.map((table) => ( - - {table} - - ))} +
+
{connectedTables.length}
+
연결된 테이블
+
+
+
+ {relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)}
- - - )} +
연결된 컬럼
+
+
- {/* 관계 목록 미리보기 */} - {relationships.length > 0 && ( - - - 관계 목록 - - -
- {relationships.map((relationship, index) => ( -
-
-
- - {relationship.connectionType || "simple-key"} - - - {relationship.relationshipName || `${relationship.fromTable} → ${relationship.toTable}`} - -
-
- {relationship.fromTable} → {relationship.toTable} -
-
- - {relationship.connectionType} + {/* 연결된 테이블 목록 */} + {connectedTables.length > 0 && ( + + + 연결된 테이블 + + +
+ {connectedTables.map((table) => ( + + {table} -
- ))} -
- - - )} - - {/* 관계가 없는 경우 안내 */} - {relationships.length === 0 && ( -
-
📭
-
생성된 관계가 없습니다.
-
테이블을 추가하고 컬럼을 연결해서 관계를 생성해보세요.
-
- )} -
- - - -
+ + )} - - - -
+ + {/* 관계 목록 미리보기 */} + {relationships.length > 0 && ( + + + 관계 목록 + + +
+ {relationships.map((relationship, index) => ( +
+
+
+ + {relationship.connectionType || "simple-key"} + + + {relationship.relationshipName || `${relationship.fromTable} → ${relationship.toTable}`} + +
+
+ {relationship.fromTable} → {relationship.toTable} +
+
+ + {relationship.connectionType} + +
+ ))} +
+
+
+ )} + + {/* 관계가 없는 경우 안내 */} + {relationships.length === 0 && ( +
+
📭
+
생성된 관계가 없습니다.
+
테이블을 추가하고 컬럼을 연결해서 관계를 생성해보세요.
+
+ )} +
+ + + + + + + + + {/* 저장 성공 알림 모달 */} + + + + + + 관계도 저장 완료 + + + {savedDiagramName} 관계도가 성공적으로 저장되었습니다. +
+ + 저장된 관계도는 관리 메뉴에서 확인하고 수정할 수 있습니다. + +
+
+ + 확인 + +
+
+ ); }; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 3b6ff3b6..2660014f 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -126,9 +126,9 @@ apiClient.interceptors.response.use( // 409 에러 (중복 데이터)는 조용하게 처리 if (status === 409) { - // 중복 검사 API는 완전히 조용하게 처리 - if (url?.includes("/check-duplicate")) { - // 중복 검사는 정상적인 비즈니스 로직이므로 콘솔 출력 없음 + // 중복 검사 API와 관계도 저장은 완전히 조용하게 처리 + if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) { + // 중복 검사와 관계도 중복 이름은 정상적인 비즈니스 로직이므로 콘솔 출력 없음 return Promise.reject(error); } diff --git a/frontend/lib/api/dataflow.ts b/frontend/lib/api/dataflow.ts index 48a59529..d2acdf95 100644 --- a/frontend/lib/api/dataflow.ts +++ b/frontend/lib/api/dataflow.ts @@ -824,7 +824,15 @@ export class DataFlowAPI { return response.data.data as JsonDataFlowDiagram; } catch (error) { - console.error("JSON 관계도 생성 오류:", error); + // 409 에러(중복 이름)는 콘솔 로그 출력하지 않음 + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as any; + if (axiosError.response?.status !== 409) { + console.error("JSON 관계도 생성 오류:", error); + } + } else { + console.error("JSON 관계도 생성 오류:", error); + } throw error; } } @@ -859,7 +867,15 @@ export class DataFlowAPI { return response.data.data as JsonDataFlowDiagram; } catch (error) { - console.error("JSON 관계도 수정 오류:", error); + // 409 에러(중복 이름)는 콘솔 로그 출력하지 않음 + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as any; + if (axiosError.response?.status !== 409) { + console.error("JSON 관계도 수정 오류:", error); + } + } else { + console.error("JSON 관계도 수정 오류:", error); + } throw error; } } From 81d760532b4482da8e53f917add9201591a62121 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 19 Sep 2025 16:42:33 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=95=A1=EC=85=98=EC=97=90=20=EB=85=BC?= =?UTF-8?q?=EB=A6=AC=EC=97=B0=EC=82=B0=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/dataflowControlService.ts | 41 +++- .../components/dataflow/DataFlowDesigner.tsx | 1 + .../dataflow/connection/DataSaveSettings.tsx | 198 +++++++++++------- frontend/types/connectionTypes.ts | 1 + 4 files changed, 158 insertions(+), 83 deletions(-) diff --git a/backend-node/src/services/dataflowControlService.ts b/backend-node/src/services/dataflowControlService.ts index a04e5eee..d706935f 100644 --- a/backend-node/src/services/dataflowControlService.ts +++ b/backend-node/src/services/dataflowControlService.ts @@ -19,6 +19,7 @@ export interface ControlAction { id: string; name: string; actionType: "insert" | "update" | "delete"; + logicalOperator?: "AND" | "OR"; // 액션 간 논리 연산자 (첫 번째 액션 제외) conditions: ControlCondition[]; fieldMappings: { sourceField?: string; @@ -136,17 +137,41 @@ export class DataflowControlService { }; } - // 액션 실행 + // 액션 실행 (논리 연산자 지원) const executedActions = []; const errors = []; + let previousActionSuccess = false; + let shouldSkipRemainingActions = false; + + for (let i = 0; i < targetPlan.actions.length; i++) { + const action = targetPlan.actions[i]; - for (const action of targetPlan.actions) { try { + // 논리 연산자에 따른 실행 여부 결정 + if ( + i > 0 && + action.logicalOperator === "OR" && + previousActionSuccess + ) { + console.log( + `⏭️ OR 조건으로 인해 액션 건너뛰기: ${action.name} (이전 액션 성공)` + ); + continue; + } + + if (shouldSkipRemainingActions && action.logicalOperator === "AND") { + console.log( + `⏭️ 이전 액션 실패로 인해 AND 체인 액션 건너뛰기: ${action.name}` + ); + continue; + } + console.log(`⚡ 액션 실행: ${action.name} (${action.actionType})`); console.log(`📋 액션 상세 정보:`, { actionId: action.id, actionName: action.name, actionType: action.actionType, + logicalOperator: action.logicalOperator, conditions: action.conditions, fieldMappings: action.fieldMappings, }); @@ -163,6 +188,10 @@ export class DataflowControlService { console.log( `⚠️ 액션 조건 미충족: ${actionConditionResult.reason}` ); + previousActionSuccess = false; + if (action.logicalOperator === "AND") { + shouldSkipRemainingActions = true; + } continue; } } @@ -173,11 +202,19 @@ export class DataflowControlService { actionName: action.name, result: actionResult, }); + + previousActionSuccess = true; + shouldSkipRemainingActions = false; // 성공했으므로 다시 실행 가능 } catch (error) { console.error(`❌ 액션 실행 오류: ${action.name}`, error); const errorMessage = error instanceof Error ? error.message : String(error); errors.push(`액션 '${action.name}' 실행 오류: ${errorMessage}`); + + previousActionSuccess = false; + if (action.logicalOperator === "AND") { + shouldSkipRemainingActions = true; + } } } diff --git a/frontend/components/dataflow/DataFlowDesigner.tsx b/frontend/components/dataflow/DataFlowDesigner.tsx index daf2b454..d1eb0003 100644 --- a/frontend/components/dataflow/DataFlowDesigner.tsx +++ b/frontend/components/dataflow/DataFlowDesigner.tsx @@ -669,6 +669,7 @@ export const DataFlowDesigner: React.FC = ({ id: action.id as string, name: action.name as string, actionType: action.actionType as "insert" | "update" | "delete" | "upsert", + logicalOperator: action.logicalOperator as "AND" | "OR" | undefined, // 논리 연산자 추가 fieldMappings: ((action.fieldMappings as Record[]) || []).map( (mapping: Record) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/frontend/components/dataflow/connection/DataSaveSettings.tsx b/frontend/components/dataflow/connection/DataSaveSettings.tsx index 8fd3937e..6b867c99 100644 --- a/frontend/components/dataflow/connection/DataSaveSettings.tsx +++ b/frontend/components/dataflow/connection/DataSaveSettings.tsx @@ -38,6 +38,8 @@ export const DataSaveSettings: React.FC = ({ id: `action_${settings.actions.length + 1}`, name: `액션 ${settings.actions.length + 1}`, actionType: "insert" as const, + // 첫 번째 액션이 아니면 기본적으로 AND 연산자 추가 + ...(settings.actions.length > 0 && { logicalOperator: "AND" as const }), fieldMappings: [], conditions: [], splitConfig: { @@ -60,6 +62,12 @@ export const DataSaveSettings: React.FC = ({ const removeAction = (actionIndex: number) => { const newActions = settings.actions.filter((_, i) => i !== actionIndex); + + // 첫 번째 액션을 삭제했다면, 새로운 첫 번째 액션의 logicalOperator 제거 + if (actionIndex === 0 && newActions.length > 0) { + delete newActions[0].logicalOperator; + } + onSettingsChange({ ...settings, actions: newActions }); }; @@ -87,104 +95,132 @@ export const DataSaveSettings: React.FC = ({ ) : (
{settings.actions.map((action, actionIndex) => ( -
-
- updateAction(actionIndex, "name", e.target.value)} - className="h-7 flex-1 text-xs font-medium" - placeholder="액션 이름" - /> - -
- -
- {/* 액션 타입 */} -
- - +
+ {/* 첫 번째 액션이 아닌 경우 논리 연산자 표시 */} + {actionIndex > 0 && ( +
+
+ 이전 액션과의 관계: + +
-
- - {/* 액션별 개별 실행 조건 */} - - - {/* 데이터 분할 설정 - DELETE 액션은 제외 */} - {action.actionType !== "delete" && ( - )} - {/* 필드 매핑 - DELETE 액션은 제외 */} - {action.actionType !== "delete" && ( - +
+ updateAction(actionIndex, "name", e.target.value)} + className="h-7 flex-1 text-xs font-medium" + placeholder="액션 이름" + /> + +
+ +
+ {/* 액션 타입 */} +
+ + +
+
+ + {/* 액션별 개별 실행 조건 */} + - )} - {/* DELETE 액션일 때 안내 메시지 */} - {action.actionType === "delete" && ( -
-
-
- ℹ️ -
-
DELETE 액션 정보
-
- DELETE 액션은 실행조건만 필요합니다. -
- • 데이터 분할 설정: 불필요 (삭제 작업에는 분할이 의미 없음) -
- • 필드 매핑: 불필요 (조건에 맞는 데이터를 통째로 삭제) -
- 위에서 설정한 실행조건에 맞는 모든 데이터가 삭제됩니다. + {/* 데이터 분할 설정 - DELETE 액션은 제외 */} + {action.actionType !== "delete" && ( + + )} + + {/* 필드 매핑 - DELETE 액션은 제외 */} + {action.actionType !== "delete" && ( + + )} + + {/* DELETE 액션일 때 안내 메시지 */} + {action.actionType === "delete" && ( +
+
+
+ ℹ️ +
+
DELETE 액션 정보
+
+ DELETE 액션은 실행조건만 필요합니다. +
+ • 데이터 분할 설정: 불필요 (삭제 작업에는 분할이 의미 없음) +
+ • 필드 매핑: 불필요 (조건에 맞는 데이터를 통째로 삭제) +
+ 위에서 설정한 실행조건에 맞는 모든 데이터가 삭제됩니다. +
-
- )} + )} +
))}
diff --git a/frontend/types/connectionTypes.ts b/frontend/types/connectionTypes.ts index 237a5435..18c36cde 100644 --- a/frontend/types/connectionTypes.ts +++ b/frontend/types/connectionTypes.ts @@ -48,6 +48,7 @@ export interface DataSaveSettings { id: string; name: string; actionType: "insert" | "update" | "delete" | "upsert"; + logicalOperator?: "AND" | "OR"; // 액션 간 논리 연산자 (첫 번째 액션 제외) conditions?: ConditionNode[]; fieldMappings: Array<{ sourceTable?: string; From 43e335d2715cc0783967e12c7aa5fb475575d0a0 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Sun, 21 Sep 2025 09:53:05 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EC=A0=84=EC=B2=B4=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=9B=B9=20=ED=83=80=EC=9E=85=EB=B3=84=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../controllers/entityReferenceController.ts | 191 +++++++++++ .../src/routes/entityReferenceRoutes.ts | 27 ++ .../dataflow/ConnectionSetupModal.tsx | 1 + .../dataflow/condition/ConditionRenderer.tsx | 44 +-- .../condition/ConditionalSettings.tsx | 3 + .../dataflow/condition/WebTypeInput.tsx | 322 ++++++++++++++++++ .../connection/ActionConditionRenderer.tsx | 40 ++- frontend/lib/api/entityReference.ts | 93 +++++ 9 files changed, 686 insertions(+), 37 deletions(-) create mode 100644 backend-node/src/controllers/entityReferenceController.ts create mode 100644 backend-node/src/routes/entityReferenceRoutes.ts create mode 100644 frontend/components/dataflow/condition/WebTypeInput.tsx create mode 100644 frontend/lib/api/entityReference.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 8a01bdaf..39262f81 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes"; import dataRoutes from "./routes/dataRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; +import entityReferenceRoutes from "./routes/entityReferenceRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -125,6 +126,7 @@ app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes); +app.use("/api/entity-reference", entityReferenceRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/entityReferenceController.ts b/backend-node/src/controllers/entityReferenceController.ts new file mode 100644 index 00000000..1935065e --- /dev/null +++ b/backend-node/src/controllers/entityReferenceController.ts @@ -0,0 +1,191 @@ +import { Request, Response } from "express"; +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +export interface EntityReferenceOption { + value: string; + label: string; +} + +export interface EntityReferenceData { + options: EntityReferenceOption[]; + referenceInfo: { + referenceTable: string; + referenceColumn: string; + displayColumn: string | null; + }; +} + +export interface CodeReferenceData { + options: EntityReferenceOption[]; + codeCategory: string; +} + +export class EntityReferenceController { + /** + * 엔티티 참조 데이터 조회 + * GET /api/entity-reference/:tableName/:columnName + */ + static async getEntityReferenceData(req: Request, res: Response) { + try { + const { tableName, columnName } = req.params; + const { limit = 100, search } = req.query; + + logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, { + limit, + search, + }); + + // 컬럼 정보 조회 + const columnInfo = await prisma.column_labels.findFirst({ + where: { + table_name: tableName, + column_name: columnName, + }, + }); + + if (!columnInfo) { + return res.status(404).json({ + success: false, + message: `컬럼 '${tableName}.${columnName}'을 찾을 수 없습니다.`, + }); + } + + // detailSettings에서 참조 테이블 정보 추출 + let referenceTable = ""; + let displayColumn = "name"; + + try { + if (columnInfo.detail_settings) { + const detailSettings = JSON.parse(columnInfo.detail_settings); + referenceTable = detailSettings.referenceTable || ""; + displayColumn = detailSettings.displayColumn || "name"; + } + } catch (error) { + logger.warn("detailSettings 파싱 실패:", error); + } + + if (!referenceTable) { + return res.status(400).json({ + success: false, + message: `컬럼 '${columnName}'에 참조 테이블이 설정되지 않았습니다.`, + }); + } + + // 동적 쿼리로 참조 데이터 조회 + let query = `SELECT id, ${displayColumn} as display_name FROM ${referenceTable}`; + const queryParams: any[] = []; + + // 검색 조건 추가 + if (search) { + query += ` WHERE ${displayColumn} ILIKE $1`; + queryParams.push(`%${search}%`); + } + + query += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`; + queryParams.push(Number(limit)); + + const referenceData = await prisma.$queryRawUnsafe(query, ...queryParams); + + // 옵션 형태로 변환 + const options: EntityReferenceOption[] = (referenceData as any[]).map( + (row) => ({ + value: String(row.id), + label: String(row.display_name || row.id), + }) + ); + + const result: EntityReferenceData = { + options, + referenceInfo: { + referenceTable, + referenceColumn: "id", + displayColumn, + }, + }; + + logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`); + + return res.json({ + success: true, + message: "엔티티 참조 데이터 조회 성공", + data: result, + }); + } catch (error) { + logger.error("엔티티 참조 데이터 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "엔티티 참조 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 공통 코드 데이터 조회 + * GET /api/entity-reference/code/:codeCategory + */ + static async getCodeData(req: Request, res: Response) { + try { + const { codeCategory } = req.params; + const { limit = 100, search } = req.query; + + logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, { + limit, + search, + }); + + // code_info 테이블에서 공통 코드 조회 + let whereClause: any = { + code_category: codeCategory, + is_active: "Y", + }; + + // 검색 조건 추가 + if (search) { + whereClause.OR = [ + { code_value: { contains: String(search), mode: "insensitive" } }, + { code_name: { contains: String(search), mode: "insensitive" } }, + ]; + } + + const codes = await prisma.code_info.findMany({ + where: whereClause, + orderBy: { sort_order: "asc" }, + take: Number(limit), + select: { + code_value: true, + code_name: true, + }, + }); + + // 옵션 형태로 변환 + const options: EntityReferenceOption[] = codes.map((code: any) => ({ + value: code.code_value || "", + label: code.code_name || "", + })); + + const result: CodeReferenceData = { + options, + codeCategory, + }; + + logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`); + + return res.json({ + success: true, + message: "공통 코드 데이터 조회 성공", + data: result, + }); + } catch (error) { + logger.error("공통 코드 데이터 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "공통 코드 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +} diff --git a/backend-node/src/routes/entityReferenceRoutes.ts b/backend-node/src/routes/entityReferenceRoutes.ts new file mode 100644 index 00000000..996d569c --- /dev/null +++ b/backend-node/src/routes/entityReferenceRoutes.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import { EntityReferenceController } from "../controllers/entityReferenceController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +/** + * GET /api/entity-reference/code/:codeCategory + * 공통 코드 데이터 조회 + */ +router.get( + "/code/:codeCategory", + authenticateToken, + EntityReferenceController.getCodeData +); + +/** + * GET /api/entity-reference/:tableName/:columnName + * 엔티티 참조 데이터 조회 + */ +router.get( + "/:tableName/:columnName", + authenticateToken, + EntityReferenceController.getEntityReferenceData +); + +export default router; diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index cf3c55c0..7c70f09e 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -675,6 +675,7 @@ export const ConnectionSetupModal: React.FC = ({ void; onRemoveCondition: (index: number) => void; getCurrentGroupLevel: (index: number) => number; @@ -19,41 +21,43 @@ interface ConditionRendererProps { export const ConditionRenderer: React.FC = ({ conditions, fromTableColumns, + fromTableName, onUpdateCondition, onRemoveCondition, getCurrentGroupLevel, }) => { const renderConditionValue = (condition: ConditionNode, index: number) => { const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); - const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; - const inputType = getInputTypeForDataType(dataType); - if (dataType.includes("bool")) { - return ( - - ); - } else { + if (!selectedColumn) { + // 컬럼이 선택되지 않은 경우 기본 input return ( onUpdateCondition(index, "value", e.target.value)} className="h-8 flex-1 text-xs" /> ); } + + // 테이블명 정보를 포함한 컬럼 객체 생성 + const columnWithTableName = { + ...selectedColumn, + tableName: fromTableName, + }; + + // WebType 기반 input 사용 + return ( + onUpdateCondition(index, "value", value)} + className="h-8 flex-1 text-xs" + placeholder="값" + /> + ); }; return ( diff --git a/frontend/components/dataflow/condition/ConditionalSettings.tsx b/frontend/components/dataflow/condition/ConditionalSettings.tsx index a863e36b..882ff09c 100644 --- a/frontend/components/dataflow/condition/ConditionalSettings.tsx +++ b/frontend/components/dataflow/condition/ConditionalSettings.tsx @@ -10,6 +10,7 @@ import { ConditionRenderer } from "./ConditionRenderer"; interface ConditionalSettingsProps { conditions: ConditionNode[]; fromTableColumns: ColumnInfo[]; + fromTableName?: string; onAddCondition: () => void; onAddGroupStart: () => void; onAddGroupEnd: () => void; @@ -21,6 +22,7 @@ interface ConditionalSettingsProps { export const ConditionalSettings: React.FC = ({ conditions, fromTableColumns, + fromTableName, onAddCondition, onAddGroupStart, onAddGroupEnd, @@ -57,6 +59,7 @@ export const ConditionalSettings: React.FC = ({ void; + className?: string; + placeholder?: string; +} + +export const WebTypeInput: React.FC = ({ column, value, onChange, className = "", placeholder }) => { + const webType = column.webType || "text"; + const [entityOptions, setEntityOptions] = useState([]); + const [codeOptions, setCodeOptions] = useState([]); + const [loading, setLoading] = useState(false); + + // detailSettings 안전하게 파싱 + let detailSettings: any = {}; + let fallbackCodeCategory = ""; + + if (column.detailSettings && typeof column.detailSettings === "string") { + // JSON 형태인지 확인 ('{' 또는 '[' 로 시작하는지) + const trimmed = column.detailSettings.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + detailSettings = JSON.parse(column.detailSettings); + } catch (error) { + console.warn(`detailSettings JSON 파싱 실패 (${column.columnName}):`, column.detailSettings, error); + detailSettings = {}; + } + } else { + // JSON이 아닌 일반 문자열인 경우, code 타입이면 codeCategory로 사용 + if (webType === "code") { + // "공통코드: 상태" 형태에서 실제 코드 추출 시도 + if (column.detailSettings.includes(":")) { + const parts = column.detailSettings.split(":"); + if (parts.length >= 2) { + fallbackCodeCategory = parts[1].trim(); + } else { + fallbackCodeCategory = column.detailSettings; + } + } else { + fallbackCodeCategory = column.detailSettings; + } + console.log(`📝 detailSettings에서 codeCategory 추출: "${column.detailSettings}" -> "${fallbackCodeCategory}"`); + } + detailSettings = {}; + } + } else if (column.detailSettings && typeof column.detailSettings === "object") { + detailSettings = column.detailSettings; + } + + // Entity 타입일 때 참조 데이터 로드 + useEffect(() => { + console.log("🔍 WebTypeInput useEffect:", { + webType, + columnName: column.columnName, + tableName: column.tableName, + referenceTable: column.referenceTable, + displayColumn: column.displayColumn, + codeCategory: column.codeCategory, + }); + + if (webType === "entity" && column.tableName && column.columnName) { + console.log("🚀 Entity 데이터 로드 시작:", column.tableName, column.columnName); + loadEntityData(); + } else if (webType === "code" && (column.codeCategory || detailSettings.codeCategory || fallbackCodeCategory)) { + const codeCategory = column.codeCategory || detailSettings.codeCategory || fallbackCodeCategory; + console.log("🚀 Code 데이터 로드 시작:", codeCategory); + loadCodeData(); + } else { + console.log("❌ 조건 불충족 - API 호출 안함"); + } + }, [webType, column.tableName, column.columnName, column.codeCategory, fallbackCodeCategory]); + + const loadEntityData = async () => { + try { + setLoading(true); + console.log("📡 Entity API 호출:", column.tableName, column.columnName); + const data = await EntityReferenceAPI.getEntityReferenceData(column.tableName, column.columnName, { limit: 100 }); + console.log("✅ Entity API 응답:", data); + setEntityOptions(data.options); + } catch (error) { + console.error("❌ 엔티티 참조 데이터 로드 실패:", error); + setEntityOptions([]); + } finally { + setLoading(false); + } + }; + + const loadCodeData = async () => { + try { + setLoading(true); + const codeCategory = column.codeCategory || detailSettings.codeCategory || fallbackCodeCategory; + if (codeCategory) { + console.log("📡 Code API 호출:", codeCategory); + const data = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 100 }); + console.log("✅ Code API 응답:", data); + setCodeOptions(data.options); + } else { + console.warn("⚠️ codeCategory가 없어서 API 호출 안함"); + } + } catch (error) { + console.error("공통 코드 데이터 로드 실패:", error); + setCodeOptions([]); + } finally { + setLoading(false); + } + }; + + // 공통 props + const commonProps = { + value: value || "", + className, + }; + + // WebType별 렌더링 + switch (webType) { + case "text": + return ( + onChange(e.target.value)} + /> + ); + + case "number": + return ( + onChange(e.target.value)} + min={detailSettings.min} + max={detailSettings.max} + step={detailSettings.step || "any"} + /> + ); + + case "date": + const dateValue = value ? new Date(value) : undefined; + return ( + + + + + + onChange(date ? format(date, "yyyy-MM-dd") : "")} + initialFocus + /> + + + ); + + case "textarea": + return ( +