diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index c0d08083..960583ce 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -13,6 +13,7 @@ import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gr import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; +import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { AlertDialog, @@ -57,6 +58,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [successModalOpen, setSuccessModalOpen] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + // 클립보드 (복사/붙여넣기용) + const [clipboard, setClipboard] = useState(null); + // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); @@ -289,6 +293,51 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D [selectedElement], ); + // 키보드 단축키 핸들러들 + const handleCopyElement = useCallback(() => { + if (!selectedElement) return; + const element = elements.find((el) => el.id === selectedElement); + if (element) { + setClipboard(element); + } + }, [selectedElement, elements]); + + const handlePasteElement = useCallback(() => { + if (!clipboard) return; + + // 새 ID 생성 + const newId = `element-${elementCounter + 1}`; + setElementCounter((prev) => prev + 1); + + // 위치를 약간 오프셋 (오른쪽 아래로 20px씩) + const newElement: DashboardElement = { + ...clipboard, + id: newId, + position: { + x: clipboard.position.x + 20, + y: clipboard.position.y + 20, + }, + }; + + setElements((prev) => [...prev, newElement]); + setSelectedElement(newId); + }, [clipboard, elementCounter]); + + const handleDeleteSelected = useCallback(() => { + if (selectedElement) { + removeElement(selectedElement); + } + }, [selectedElement, removeElement]); + + // 키보드 단축키 활성화 + useKeyboardShortcuts({ + selectedElementId: selectedElement, + onDelete: handleDeleteSelected, + onCopy: handleCopyElement, + onPaste: handlePasteElement, + enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen, + }); + // 전체 삭제 확인 모달 열기 const clearCanvas = useCallback(() => { setClearConfirmOpen(true); diff --git a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx index 09f45411..2e6616f9 100644 --- a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { ChartDataSource, QueryResult, KeyValuePair } from "../types"; import { Card } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Plus, X, Play, AlertCircle } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; interface ApiConfigProps { dataSource: ChartDataSource; @@ -24,6 +26,106 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [testError, setTestError] = useState(null); + const [apiConnections, setApiConnections] = useState([]); + const [selectedConnectionId, setSelectedConnectionId] = useState(""); + + // 외부 API 커넥션 목록 로드 + useEffect(() => { + const loadApiConnections = async () => { + const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" }); + setApiConnections(connections); + }; + loadApiConnections(); + }, []); + + // 외부 커넥션 선택 핸들러 + const handleConnectionSelect = async (connectionId: string) => { + setSelectedConnectionId(connectionId); + + if (!connectionId || connectionId === "manual") return; + + const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId)); + if (!connection) { + console.error("커넥션을 찾을 수 없습니다:", connectionId); + return; + } + + console.log("불러온 커넥션:", connection); + + // 커넥션 설정을 API 설정에 자동 적용 + const updates: Partial = { + endpoint: connection.base_url, + }; + + const headers: KeyValuePair[] = []; + const queryParams: KeyValuePair[] = []; + + // 기본 헤더가 있으면 적용 + if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { + Object.entries(connection.default_headers).forEach(([key, value]) => { + headers.push({ + id: `header_${Date.now()}_${Math.random()}`, + key, + value, + }); + }); + console.log("기본 헤더 적용:", headers); + } + + // 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가 + if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) { + console.log("인증 설정:", connection.auth_type, connection.auth_config); + + if (connection.auth_type === "bearer" && connection.auth_config.token) { + headers.push({ + id: `header_${Date.now()}_auth`, + key: "Authorization", + value: `Bearer ${connection.auth_config.token}`, + }); + console.log("Bearer 토큰 추가"); + } else if (connection.auth_type === "api-key") { + console.log("API Key 설정:", connection.auth_config); + + if (connection.auth_config.keyName && connection.auth_config.keyValue) { + if (connection.auth_config.keyLocation === "header") { + headers.push({ + id: `header_${Date.now()}_apikey`, + key: connection.auth_config.keyName, + value: connection.auth_config.keyValue, + }); + console.log(`API Key 헤더 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`); + } else if (connection.auth_config.keyLocation === "query") { + queryParams.push({ + id: `param_${Date.now()}_apikey`, + key: connection.auth_config.keyName, + value: connection.auth_config.keyValue, + }); + console.log( + `API Key 쿼리 파라미터 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`, + ); + } + } + } else if ( + connection.auth_type === "basic" && + connection.auth_config.username && + connection.auth_config.password + ) { + const basicAuth = btoa(`${connection.auth_config.username}:${connection.auth_config.password}`); + headers.push({ + id: `header_${Date.now()}_basic`, + key: "Authorization", + value: `Basic ${basicAuth}`, + }); + console.log("Basic Auth 추가"); + } + } + + updates.headers = headers; + updates.queryParams = queryParams; + console.log("최종 업데이트:", updates); + + onChange(updates); + }; // 헤더를 배열로 정규화 (객체 형식 호환) const normalizeHeaders = (): KeyValuePair[] => { @@ -217,6 +319,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps

외부 API에서 데이터를 가져올 설정을 입력하세요

+ {/* 외부 커넥션 선택 */} + {apiConnections.length > 0 && ( + +
+ + +

외부 커넥션 관리에서 저장한 REST API 설정을 불러올 수 있습니다

+
+
+ )} + {/* API URL */}
@@ -230,13 +356,6 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps />

GET 요청을 보낼 API 엔드포인트

- - {/* HTTP 메서드 (고정) */} -
- -
GET (고정)
-

데이터 조회는 GET 메서드만 지원합니다

-
{/* 쿼리 파라미터 */} diff --git a/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts b/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..d0fab67f --- /dev/null +++ b/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,105 @@ +import { useEffect, useCallback } from "react"; + +interface KeyboardShortcutsProps { + selectedElementId: string | null; + onDelete: () => void; + onCopy: () => void; + onPaste: () => void; + onUndo?: () => void; + onRedo?: () => void; + enabled?: boolean; +} + +/** + * 대시보드 키보드 단축키 훅 + * + * 지원 단축키: + * - Delete: 선택한 요소 삭제 + * - Ctrl+C: 요소 복사 + * - Ctrl+V: 요소 붙여넣기 + * - Ctrl+Z: 실행 취소 (구현 예정) + * - Ctrl+Shift+Z: 재실행 (구현 예정) + */ +export function useKeyboardShortcuts({ + selectedElementId, + onDelete, + onCopy, + onPaste, + onUndo, + onRedo, + enabled = true, +}: KeyboardShortcutsProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!enabled) return; + + // 입력 필드에서는 단축키 비활성화 + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.contentEditable === "true" || + target.closest('[role="dialog"]') || + target.closest('[role="alertdialog"]') + ) { + return; + } + + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + const ctrlKey = isMac ? e.metaKey : e.ctrlKey; + + // Delete: 선택한 요소 삭제 + if (e.key === "Delete" || e.key === "Backspace") { + if (selectedElementId) { + e.preventDefault(); + onDelete(); + } + return; + } + + // Ctrl+C: 복사 + if (ctrlKey && e.key === "c") { + if (selectedElementId) { + e.preventDefault(); + onCopy(); + } + return; + } + + // Ctrl+V: 붙여넣기 + if (ctrlKey && e.key === "v") { + e.preventDefault(); + onPaste(); + return; + } + + // Ctrl+Z: 실행 취소 + if (ctrlKey && e.key === "z" && !e.shiftKey) { + if (onUndo) { + e.preventDefault(); + onUndo(); + } + return; + } + + // Ctrl+Shift+Z 또는 Ctrl+Y: 재실행 + if ((ctrlKey && e.shiftKey && e.key === "z") || (ctrlKey && e.key === "y")) { + if (onRedo) { + e.preventDefault(); + onRedo(); + } + return; + } + }, + [enabled, selectedElementId, onDelete, onCopy, onPaste, onUndo, onRedo], + ); + + useEffect(() => { + if (!enabled) return; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [handleKeyDown, enabled]); +} diff --git a/frontend/lib/api/externalDbConnection.ts b/frontend/lib/api/externalDbConnection.ts index aa161af7..257a7a3f 100644 --- a/frontend/lib/api/externalDbConnection.ts +++ b/frontend/lib/api/externalDbConnection.ts @@ -27,6 +27,36 @@ export interface ExternalDbConnection { updated_by?: string; } +export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; + +export interface ExternalApiConnection { + id?: number; + connection_name: string; + description?: string; + base_url: string; + default_headers: Record; + auth_type: AuthType; + auth_config?: { + keyLocation?: "header" | "query"; + keyName?: string; + keyValue?: string; + token?: string; + username?: string; + password?: string; + clientId?: string; + clientSecret?: string; + tokenUrl?: string; + accessToken?: string; + }; + timeout?: number; + company_code: string; + is_active: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; +} + export interface ExternalDbConnectionFilter { db_type?: string; is_active?: string; @@ -209,7 +239,7 @@ export class ExternalDbConnectionAPI { try { const response = await apiClient.post>( `${this.BASE_PATH}/${connectionId}/test`, - password ? { password } : undefined + password ? { password } : undefined, ); if (!response.data.success) { @@ -220,10 +250,12 @@ export class ExternalDbConnectionAPI { }; } - return response.data.data || { - success: true, - message: response.data.message || "연결 테스트가 완료되었습니다.", - }; + return ( + response.data.data || { + success: true, + message: response.data.message || "연결 테스트가 완료되었습니다.", + } + ); } catch (error) { console.error("연결 테스트 오류:", error); @@ -246,9 +278,7 @@ export class ExternalDbConnectionAPI { */ static async getTables(connectionId: number): Promise> { try { - const response = await apiClient.get>( - `${this.BASE_PATH}/${connectionId}/tables` - ); + const response = await apiClient.get>(`${this.BASE_PATH}/${connectionId}/tables`); return response.data; } catch (error) { console.error("테이블 목록 조회 오류:", error); @@ -260,7 +290,7 @@ export class ExternalDbConnectionAPI { try { console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`); const response = await apiClient.get>( - `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns` + `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`, ); console.log("컬럼 정보 API 응답:", response.data); return response.data; @@ -273,10 +303,7 @@ export class ExternalDbConnectionAPI { static async executeQuery(connectionId: number, query: string): Promise> { try { console.log("API 요청:", `${this.BASE_PATH}/${connectionId}/execute`, { query }); - const response = await apiClient.post>( - `${this.BASE_PATH}/${connectionId}/execute`, - { query } - ); + const response = await apiClient.post>(`${this.BASE_PATH}/${connectionId}/execute`, { query }); console.log("API 응답:", response.data); return response.data; } catch (error) { @@ -284,4 +311,45 @@ export class ExternalDbConnectionAPI { throw error; } } + + /** + * REST API 연결 목록 조회 (외부 커넥션에서) + */ + static async getApiConnections(filter: { is_active?: string } = {}): Promise { + try { + const params = new URLSearchParams(); + if (filter.is_active) params.append("is_active", filter.is_active); + + const response = await apiClient.get>( + `/external-rest-api-connections?${params.toString()}`, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "API 연결 목록 조회에 실패했습니다."); + } + + return response.data.data || []; + } catch (error) { + console.error("API 연결 목록 조회 오류:", error); + return []; + } + } + + /** + * 특정 REST API 연결 조회 + */ + static async getApiConnectionById(id: number): Promise { + try { + const response = await apiClient.get>(`/external-rest-api-connections/${id}`); + + if (!response.data.success || !response.data.data) { + return null; + } + + return response.data.data; + } catch (error) { + console.error("API 연결 조회 오류:", error); + return null; + } + } }