; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2025-10-22 10:10:37 +09:00
commit e290076708
4 changed files with 362 additions and 21 deletions

View File

@ -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,
@ -58,6 +59,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
// 클립보드 (복사/붙여넣기용)
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
// 화면 해상도 자동 감지
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
const [resolution, setResolution] = useState<Resolution>(screenResolution);
@ -290,6 +294,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);

View File

@ -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<QueryResult | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
// 외부 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<ChartDataSource> = {
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
<p className="mt-1 text-sm text-gray-600"> API에서 </p>
</div>
{/* 외부 커넥션 선택 */}
{apiConnections.length > 0 && (
<Card className="space-y-4 p-4">
<div>
<Label className="text-sm font-medium text-gray-700"> ()</Label>
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="저장된 커넥션 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="manual"> </SelectItem>
{apiConnections.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name}
{conn.description && <span className="ml-2 text-xs text-gray-500">({conn.description})</span>}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> REST API </p>
</div>
</Card>
)}
{/* API URL */}
<Card className="space-y-4 p-4">
<div>
@ -230,13 +356,6 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
/>
<p className="mt-1 text-xs text-gray-500">GET API </p>
</div>
{/* HTTP 메서드 (고정) */}
<div>
<Label className="text-sm font-medium text-gray-700">HTTP </Label>
<div className="mt-2 rounded border border-gray-300 bg-gray-100 p-2 text-sm text-gray-700">GET ()</div>
<p className="mt-1 text-xs text-gray-500"> GET </p>
</div>
</Card>
{/* 쿼리 파라미터 */}

View File

@ -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]);
}

View File

@ -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<string, string>;
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<ApiResponse<ConnectionTestResult>>(
`${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<ApiResponse<string[]>> {
try {
const response = await apiClient.get<ApiResponse<string[]>>(
`${this.BASE_PATH}/${connectionId}/tables`
);
const response = await apiClient.get<ApiResponse<string[]>>(`${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<ApiResponse<any[]>>(
`${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<ApiResponse<any[]>> {
try {
console.log("API 요청:", `${this.BASE_PATH}/${connectionId}/execute`, { query });
const response = await apiClient.post<ApiResponse<any[]>>(
`${this.BASE_PATH}/${connectionId}/execute`,
{ query }
);
const response = await apiClient.post<ApiResponse<any[]>>(`${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<ExternalApiConnection[]> {
try {
const params = new URLSearchParams();
if (filter.is_active) params.append("is_active", filter.is_active);
const response = await apiClient.get<ApiResponse<ExternalApiConnection[]>>(
`/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<ExternalApiConnection | null> {
try {
const response = await apiClient.get<ApiResponse<ExternalApiConnection>>(`/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;
}
}
}