From d8f73c113659e07e7d3114d1fc9c659dfa1b91a9 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 30 Sep 2025 13:23:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 새로운 기능: - 드래그 앤 드롭 대시보드 설계 도구 - SQL 쿼리 에디터 및 실시간 실행 - Recharts 기반 차트 컴포넌트 (Bar, Pie, Line) - 차트 데이터 매핑 및 설정 UI - 요소 이동, 크기 조절, 삭제 기능 - 레이아웃 저장 기능 📦 추가된 컴포넌트: - DashboardDesigner: 메인 설계 도구 - QueryEditor: SQL 쿼리 작성 및 실행 - ChartConfigPanel: 차트 설정 패널 - ChartRenderer: 실제 차트 렌더링 - CanvasElement: 드래그 가능한 캔버스 요소 🔧 기술 스택: - Recharts 라이브러리 추가 - TypeScript 타입 정의 완비 - 독립적 컴포넌트 구조로 설계 🎯 접속 경로: /admin/dashboard --- frontend/app/(main)/admin/dashboard/page.tsx | 18 + .../admin/dashboard/CanvasElement.tsx | 387 ++++++++++++++++++ .../admin/dashboard/ChartConfigPanel.tsx | 206 ++++++++++ .../admin/dashboard/DashboardCanvas.tsx | 109 +++++ .../admin/dashboard/DashboardDesigner.tsx | 175 ++++++++ .../admin/dashboard/DashboardSidebar.tsx | 113 +++++ .../admin/dashboard/DashboardToolbar.tsx | 42 ++ .../admin/dashboard/ElementConfigModal.tsx | 169 ++++++++ .../admin/dashboard/QueryEditor.tsx | 352 ++++++++++++++++ .../dashboard/charts/BarChartComponent.tsx | 100 +++++ .../admin/dashboard/charts/ChartRenderer.tsx | 195 +++++++++ .../dashboard/charts/LineChartComponent.tsx | 103 +++++ .../dashboard/charts/PieChartComponent.tsx | 96 +++++ .../admin/dashboard/charts/index.ts | 8 + frontend/components/admin/dashboard/index.ts | 13 + frontend/components/admin/dashboard/types.ts | 68 +++ frontend/package-lock.json | 305 +++++++++++++- frontend/package.json | 1 + 18 files changed, 2458 insertions(+), 2 deletions(-) create mode 100644 frontend/app/(main)/admin/dashboard/page.tsx create mode 100644 frontend/components/admin/dashboard/CanvasElement.tsx create mode 100644 frontend/components/admin/dashboard/ChartConfigPanel.tsx create mode 100644 frontend/components/admin/dashboard/DashboardCanvas.tsx create mode 100644 frontend/components/admin/dashboard/DashboardDesigner.tsx create mode 100644 frontend/components/admin/dashboard/DashboardSidebar.tsx create mode 100644 frontend/components/admin/dashboard/DashboardToolbar.tsx create mode 100644 frontend/components/admin/dashboard/ElementConfigModal.tsx create mode 100644 frontend/components/admin/dashboard/QueryEditor.tsx create mode 100644 frontend/components/admin/dashboard/charts/BarChartComponent.tsx create mode 100644 frontend/components/admin/dashboard/charts/ChartRenderer.tsx create mode 100644 frontend/components/admin/dashboard/charts/LineChartComponent.tsx create mode 100644 frontend/components/admin/dashboard/charts/PieChartComponent.tsx create mode 100644 frontend/components/admin/dashboard/charts/index.ts create mode 100644 frontend/components/admin/dashboard/index.ts create mode 100644 frontend/components/admin/dashboard/types.ts diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx new file mode 100644 index 00000000..cd65cd8a --- /dev/null +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React from 'react'; +import DashboardDesigner from '@/components/admin/dashboard/DashboardDesigner'; + +/** + * 대시보드 관리 페이지 + * - 드래그 앤 드롭으로 대시보드 레이아웃 설계 + * - 차트 및 위젯 배치 관리 + * - 독립적인 컴포넌트로 구성되어 다른 시스템에 영향 없음 + */ +export default function DashboardPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx new file mode 100644 index 00000000..fc4e7d0b --- /dev/null +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -0,0 +1,387 @@ +'use client'; + +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { DashboardElement, QueryResult } from './types'; +import { ChartRenderer } from './charts/ChartRenderer'; + +interface CanvasElementProps { + element: DashboardElement; + isSelected: boolean; + onUpdate: (id: string, updates: Partial) => void; + onRemove: (id: string) => void; + onSelect: (id: string | null) => void; + onConfigure?: (element: DashboardElement) => void; +} + +/** + * 캔버스에 배치된 개별 요소 컴포넌트 + * - 드래그로 이동 가능 + * - 크기 조절 핸들 + * - 삭제 버튼 + */ +export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 }); + const [resizeStart, setResizeStart] = useState({ + x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: '' + }); + const [chartData, setChartData] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(false); + const elementRef = useRef(null); + + // 요소 선택 처리 + const handleMouseDown = useCallback((e: React.MouseEvent) => { + // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 + if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) { + return; + } + + onSelect(element.id); + setIsDragging(true); + setDragStart({ + x: e.clientX, + y: e.clientY, + elementX: element.position.x, + elementY: element.position.y + }); + e.preventDefault(); + }, [element.id, element.position.x, element.position.y, onSelect]); + + // 리사이즈 핸들 마우스다운 + const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => { + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: element.size.width, + height: element.size.height, + elementX: element.position.x, + elementY: element.position.y, + handle + }); + }, [element.size.width, element.size.height, element.position.x, element.position.y]); + + // 마우스 이동 처리 + const handleMouseMove = useCallback((e: MouseEvent) => { + if (isDragging) { + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; + + onUpdate(element.id, { + position: { + x: Math.max(0, dragStart.elementX + deltaX), + y: Math.max(0, dragStart.elementY + deltaY) + } + }); + } else if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.elementX; + let newY = resizeStart.elementY; + + switch (resizeStart.handle) { + case 'se': // 오른쪽 아래 + newWidth = Math.max(150, resizeStart.width + deltaX); + newHeight = Math.max(150, resizeStart.height + deltaY); + break; + case 'sw': // 왼쪽 아래 + newWidth = Math.max(150, resizeStart.width - deltaX); + newHeight = Math.max(150, resizeStart.height + deltaY); + newX = resizeStart.elementX + deltaX; + break; + case 'ne': // 오른쪽 위 + newWidth = Math.max(150, resizeStart.width + deltaX); + newHeight = Math.max(150, resizeStart.height - deltaY); + newY = resizeStart.elementY + deltaY; + break; + case 'nw': // 왼쪽 위 + newWidth = Math.max(150, resizeStart.width - deltaX); + newHeight = Math.max(150, resizeStart.height - deltaY); + newX = resizeStart.elementX + deltaX; + newY = resizeStart.elementY + deltaY; + break; + } + + onUpdate(element.id, { + position: { x: Math.max(0, newX), y: Math.max(0, newY) }, + size: { width: newWidth, height: newHeight } + }); + } + }, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]); + + // 마우스 업 처리 + const handleMouseUp = useCallback(() => { + setIsDragging(false); + setIsResizing(false); + }, []); + + // 전역 마우스 이벤트 등록 + React.useEffect(() => { + if (isDragging || isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); + + // 데이터 로딩 + const loadChartData = useCallback(async () => { + if (!element.dataSource?.query || element.type !== 'chart') { + return; + } + + setIsLoadingData(true); + try { + // 실제 API 호출 대신 샘플 데이터 생성 + const sampleData = generateSampleData(element.dataSource.query, element.subtype); + setChartData(sampleData); + } catch (error) { + console.error('데이터 로딩 오류:', error); + setChartData(null); + } finally { + setIsLoadingData(false); + } + }, [element.dataSource?.query, element.type, element.subtype]); + + // 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩 + useEffect(() => { + loadChartData(); + }, [loadChartData]); + + // 자동 새로고침 설정 + useEffect(() => { + if (!element.dataSource?.refreshInterval || element.dataSource.refreshInterval === 0) { + return; + } + + const interval = setInterval(loadChartData, element.dataSource.refreshInterval); + return () => clearInterval(interval); + }, [element.dataSource?.refreshInterval, loadChartData]); + + // 요소 삭제 + const handleRemove = useCallback(() => { + onRemove(element.id); + }, [element.id, onRemove]); + + // 스타일 클래스 생성 + const getContentClass = () => { + if (element.type === 'chart') { + switch (element.subtype) { + case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600'; + case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500'; + case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400'; + default: return 'bg-gray-200'; + } + } else if (element.type === 'widget') { + switch (element.subtype) { + case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400'; + case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800'; + default: return 'bg-gray-200'; + } + } + return 'bg-gray-200'; + }; + + return ( +
+ {/* 헤더 */} +
+ {element.title} +
+ {/* 설정 버튼 */} + {onConfigure && ( + + )} + {/* 삭제 버튼 */} + +
+
+ + {/* 내용 */} +
+ {element.type === 'chart' ? ( + // 차트 렌더링 +
+ {isLoadingData ? ( +
+
+
+
데이터 로딩 중...
+
+
+ ) : ( + + )} +
+ ) : ( + // 위젯 렌더링 (기존 방식) +
+
+
+ {element.type === 'widget' && element.subtype === 'exchange' && '💱'} + {element.type === 'widget' && element.subtype === 'weather' && '☁️'} +
+
{element.content}
+
+
+ )} +
+ + {/* 리사이즈 핸들 (선택된 요소에만 표시) */} + {isSelected && ( + <> + + + + + + )} +
+ ); +} + +interface ResizeHandleProps { + position: 'nw' | 'ne' | 'sw' | 'se'; + onMouseDown: (e: React.MouseEvent, handle: string) => void; +} + +/** + * 크기 조절 핸들 컴포넌트 + */ +function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) { + const getPositionClass = () => { + switch (position) { + case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize'; + case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize'; + case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize'; + case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize'; + } + }; + + return ( +
onMouseDown(e, position)} + /> + ); +} + +/** + * 샘플 데이터 생성 함수 (실제 API 호출 대신 사용) + */ +function generateSampleData(query: string, chartType: string): QueryResult { + // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 + const isMonthly = query.toLowerCase().includes('month'); + const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출'); + const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자'); + const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품'); + + let columns: string[]; + let rows: Record[]; + + if (isMonthly && isSales) { + // 월별 매출 데이터 + columns = ['month', 'sales', 'order_count']; + rows = [ + { month: '2024-01', sales: 1200000, order_count: 45 }, + { month: '2024-02', sales: 1350000, order_count: 52 }, + { month: '2024-03', sales: 1180000, order_count: 41 }, + { month: '2024-04', sales: 1420000, order_count: 58 }, + { month: '2024-05', sales: 1680000, order_count: 67 }, + { month: '2024-06', sales: 1540000, order_count: 61 }, + ]; + } else if (isUsers) { + // 사용자 가입 추이 + columns = ['week', 'new_users']; + rows = [ + { week: '2024-W10', new_users: 23 }, + { week: '2024-W11', new_users: 31 }, + { week: '2024-W12', new_users: 28 }, + { week: '2024-W13', new_users: 35 }, + { week: '2024-W14', new_users: 42 }, + { week: '2024-W15', new_users: 38 }, + ]; + } else if (isProducts) { + // 상품별 판매량 + columns = ['product_name', 'total_sold', 'revenue']; + rows = [ + { product_name: '스마트폰', total_sold: 156, revenue: 234000000 }, + { product_name: '노트북', total_sold: 89, revenue: 178000000 }, + { product_name: '태블릿', total_sold: 134, revenue: 67000000 }, + { product_name: '이어폰', total_sold: 267, revenue: 26700000 }, + { product_name: '스마트워치', total_sold: 98, revenue: 49000000 }, + ]; + } else { + // 기본 샘플 데이터 + columns = ['category', 'value', 'count']; + rows = [ + { category: 'A', value: 100, count: 10 }, + { category: 'B', value: 150, count: 15 }, + { category: 'C', value: 120, count: 12 }, + { category: 'D', value: 180, count: 18 }, + { category: 'E', value: 90, count: 9 }, + ]; + } + + return { + columns, + rows, + totalRows: rows.length, + executionTime: Math.floor(Math.random() * 100) + 50, // 50-150ms + }; +} diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx new file mode 100644 index 00000000..11f3865d --- /dev/null +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -0,0 +1,206 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { ChartConfig, QueryResult } from './types'; + +interface ChartConfigPanelProps { + config?: ChartConfig; + queryResult?: QueryResult; + onConfigChange: (config: ChartConfig) => void; +} + +/** + * 차트 설정 패널 컴포넌트 + * - 데이터 필드 매핑 설정 + * - 차트 스타일 설정 + * - 실시간 미리보기 + */ +export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) { + const [currentConfig, setCurrentConfig] = useState(config || {}); + + // 설정 업데이트 + const updateConfig = useCallback((updates: Partial) => { + const newConfig = { ...currentConfig, ...updates }; + setCurrentConfig(newConfig); + onConfigChange(newConfig); + }, [currentConfig, onConfigChange]); + + // 사용 가능한 컬럼 목록 + const availableColumns = queryResult?.columns || []; + const sampleData = queryResult?.rows?.[0] || {}; + + return ( +
+

⚙️ 차트 설정

+ + {/* 쿼리 결과가 없을 때 */} + {!queryResult && ( +
+
+ 💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다. +
+
+ )} + + {/* 데이터 필드 매핑 */} + {queryResult && ( + <> + {/* 차트 제목 */} +
+ + updateConfig({ title: e.target.value })} + placeholder="차트 제목을 입력하세요" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+ + {/* X축 설정 */} +
+ + +
+ + {/* Y축 설정 */} +
+ + +
+ + {/* 집계 함수 */} +
+ + +
+ + {/* 그룹핑 필드 (선택사항) */} +
+ + +
+ + {/* 차트 색상 */} +
+ +
+ {[ + ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], // 기본 + ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'], // 밝은 + ['#1F2937', '#374151', '#6B7280', '#9CA3AF'], // 회색 + ['#DC2626', '#EA580C', '#CA8A04', '#65A30D'], // 따뜻한 + ].map((colorSet, setIdx) => ( + + ))} +
+
+ + {/* 범례 표시 */} +
+ updateConfig({ showLegend: e.target.checked })} + className="rounded" + /> + +
+ + {/* 설정 미리보기 */} +
+
📋 설정 미리보기
+
+
X축: {currentConfig.xAxis || '미설정'}
+
Y축: {currentConfig.yAxis || '미설정'}
+
집계: {currentConfig.aggregation || 'sum'}
+ {currentConfig.groupBy && ( +
그룹핑: {currentConfig.groupBy}
+ )} +
데이터 행 수: {queryResult.rows.length}개
+
+
+ + {/* 필수 필드 확인 */} + {(!currentConfig.xAxis || !currentConfig.yAxis) && ( +
+
+ ⚠️ X축과 Y축을 모두 설정해야 차트가 표시됩니다. +
+
+ )} + + )} +
+ ); +} diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx new file mode 100644 index 00000000..d86765a3 --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React, { forwardRef, useState, useCallback } from 'react'; +import { DashboardElement, ElementType, ElementSubtype, DragData } from './types'; +import { CanvasElement } from './CanvasElement'; + +interface DashboardCanvasProps { + elements: DashboardElement[]; + selectedElement: string | null; + onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void; + onUpdateElement: (id: string, updates: Partial) => void; + onRemoveElement: (id: string) => void; + onSelectElement: (id: string | null) => void; + onConfigureElement?: (element: DashboardElement) => void; +} + +/** + * 대시보드 캔버스 컴포넌트 + * - 드래그 앤 드롭 영역 + * - 그리드 배경 + * - 요소 배치 및 관리 + */ +export const DashboardCanvas = forwardRef( + ({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => { + const [isDragOver, setIsDragOver] = useState(false); + + // 드래그 오버 처리 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setIsDragOver(true); + }, []); + + // 드래그 리브 처리 + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (e.currentTarget === e.target) { + setIsDragOver(false); + } + }, []); + + // 드롭 처리 + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + try { + const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json')); + + if (!ref || typeof ref === 'function') return; + + const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; + + // 캔버스 스크롤을 고려한 정확한 위치 계산 + const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0); + const y = e.clientY - rect.top + (ref.current?.scrollTop || 0); + + onCreateElement(dragData.type, dragData.subtype, x, y); + } catch (error) { + console.error('드롭 데이터 파싱 오류:', error); + } + }, [ref, onCreateElement]); + + // 캔버스 클릭 시 선택 해제 + const handleCanvasClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onSelectElement(null); + } + }, [onSelectElement]); + + return ( +
+ {/* 배치된 요소들 렌더링 */} + {elements.map((element) => ( + + ))} +
+ ); + } +); + +DashboardCanvas.displayName = 'DashboardCanvas'; diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx new file mode 100644 index 00000000..05f0989b --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -0,0 +1,175 @@ +'use client'; + +import React, { useState, useRef, useCallback } from 'react'; +import { DashboardCanvas } from './DashboardCanvas'; +import { DashboardSidebar } from './DashboardSidebar'; +import { DashboardToolbar } from './DashboardToolbar'; +import { ElementConfigModal } from './ElementConfigModal'; +import { DashboardElement, ElementType, ElementSubtype } from './types'; + +/** + * 대시보드 설계 도구 메인 컴포넌트 + * - 드래그 앤 드롭으로 차트/위젯 배치 + * - 요소 이동, 크기 조절, 삭제 기능 + * - 레이아웃 저장/불러오기 기능 + */ +export default function DashboardDesigner() { + const [elements, setElements] = useState([]); + const [selectedElement, setSelectedElement] = useState(null); + const [elementCounter, setElementCounter] = useState(0); + const [configModalElement, setConfigModalElement] = useState(null); + const canvasRef = useRef(null); + + // 새로운 요소 생성 + const createElement = useCallback(( + type: ElementType, + subtype: ElementSubtype, + x: number, + y: number + ) => { + const newElement: DashboardElement = { + id: `element-${elementCounter + 1}`, + type, + subtype, + position: { x, y }, + size: { width: 250, height: 200 }, + title: getElementTitle(type, subtype), + content: getElementContent(type, subtype) + }; + + setElements(prev => [...prev, newElement]); + setElementCounter(prev => prev + 1); + setSelectedElement(newElement.id); + }, [elementCounter]); + + // 요소 업데이트 + const updateElement = useCallback((id: string, updates: Partial) => { + setElements(prev => prev.map(el => + el.id === id ? { ...el, ...updates } : el + )); + }, []); + + // 요소 삭제 + const removeElement = useCallback((id: string) => { + setElements(prev => prev.filter(el => el.id !== id)); + if (selectedElement === id) { + setSelectedElement(null); + } + }, [selectedElement]); + + // 전체 삭제 + const clearCanvas = useCallback(() => { + if (window.confirm('모든 요소를 삭제하시겠습니까?')) { + setElements([]); + setSelectedElement(null); + setElementCounter(0); + } + }, []); + + // 요소 설정 모달 열기 + const openConfigModal = useCallback((element: DashboardElement) => { + setConfigModalElement(element); + }, []); + + // 요소 설정 모달 닫기 + const closeConfigModal = useCallback(() => { + setConfigModalElement(null); + }, []); + + // 요소 설정 저장 + const saveElementConfig = useCallback((updatedElement: DashboardElement) => { + updateElement(updatedElement.id, updatedElement); + }, [updateElement]); + + // 레이아웃 저장 + const saveLayout = useCallback(() => { + const layoutData = { + elements: elements.map(el => ({ + type: el.type, + subtype: el.subtype, + position: el.position, + size: el.size, + title: el.title, + dataSource: el.dataSource, + chartConfig: el.chartConfig + })), + timestamp: new Date().toISOString() + }; + + console.log('저장된 레이아웃:', JSON.stringify(layoutData, null, 2)); + alert('레이아웃이 콘솔에 저장되었습니다. (F12를 눌러 확인하세요)'); + }, [elements]); + + return ( +
+ {/* 캔버스 영역 */} +
+ + +
+ + {/* 사이드바 */} + + + {/* 요소 설정 모달 */} + {configModalElement && ( + + )} +
+ ); +} + +// 요소 제목 생성 헬퍼 함수 +function getElementTitle(type: ElementType, subtype: ElementSubtype): string { + if (type === 'chart') { + switch (subtype) { + case 'bar': return '📊 바 차트'; + case 'pie': return '🥧 원형 차트'; + case 'line': return '📈 꺾은선 차트'; + default: return '📊 차트'; + } + } else if (type === 'widget') { + switch (subtype) { + case 'exchange': return '💱 환율 위젯'; + case 'weather': return '☁️ 날씨 위젯'; + default: return '🔧 위젯'; + } + } + return '요소'; +} + +// 요소 내용 생성 헬퍼 함수 +function getElementContent(type: ElementType, subtype: ElementSubtype): string { + if (type === 'chart') { + switch (subtype) { + case 'bar': return '바 차트가 여기에 표시됩니다'; + case 'pie': return '원형 차트가 여기에 표시됩니다'; + case 'line': return '꺾은선 차트가 여기에 표시됩니다'; + default: return '차트가 여기에 표시됩니다'; + } + } else if (type === 'widget') { + switch (subtype) { + case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450'; + case 'weather': return '서울\n23°C\n구름 많음'; + default: return '위젯 내용이 여기에 표시됩니다'; + } + } + return '내용이 여기에 표시됩니다'; +} diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx new file mode 100644 index 00000000..4e43b93d --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -0,0 +1,113 @@ +'use client'; + +import React from 'react'; +import { DragData, ElementType, ElementSubtype } from './types'; + +/** + * 대시보드 사이드바 컴포넌트 + * - 드래그 가능한 차트/위젯 목록 + * - 카테고리별 구분 + */ +export function DashboardSidebar() { + // 드래그 시작 처리 + const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { + const dragData: DragData = { type, subtype }; + e.dataTransfer.setData('application/json', JSON.stringify(dragData)); + e.dataTransfer.effectAllowed = 'copy'; + }; + + return ( +
+ {/* 차트 섹션 */} +
+

+ 📊 차트 종류 +

+ +
+ + + +
+
+ + {/* 위젯 섹션 */} +
+

+ 🔧 위젯 종류 +

+ +
+ + +
+
+
+ ); +} + +interface DraggableItemProps { + icon: string; + title: string; + type: ElementType; + subtype: ElementSubtype; + className?: string; + onDragStart: (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => void; +} + +/** + * 드래그 가능한 아이템 컴포넌트 + */ +function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) { + return ( +
onDragStart(e, type, subtype)} + > + {icon} + {title} +
+ ); +} diff --git a/frontend/components/admin/dashboard/DashboardToolbar.tsx b/frontend/components/admin/dashboard/DashboardToolbar.tsx new file mode 100644 index 00000000..59a7584a --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardToolbar.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +interface DashboardToolbarProps { + onClearCanvas: () => void; + onSaveLayout: () => void; +} + +/** + * 대시보드 툴바 컴포넌트 + * - 전체 삭제, 레이아웃 저장 등 주요 액션 버튼 + */ +export function DashboardToolbar({ onClearCanvas, onSaveLayout }: DashboardToolbarProps) { + return ( +
+ + + +
+ ); +} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx new file mode 100644 index 00000000..765b4ef1 --- /dev/null +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from './types'; +import { QueryEditor } from './QueryEditor'; +import { ChartConfigPanel } from './ChartConfigPanel'; + +interface ElementConfigModalProps { + element: DashboardElement; + isOpen: boolean; + onClose: () => void; + onSave: (element: DashboardElement) => void; +} + +/** + * 요소 설정 모달 컴포넌트 + * - 차트/위젯 데이터 소스 설정 + * - 쿼리 에디터 통합 + * - 차트 설정 패널 통합 + */ +export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) { + const [dataSource, setDataSource] = useState( + element.dataSource || { type: 'database', refreshInterval: 30000 } + ); + const [chartConfig, setChartConfig] = useState( + element.chartConfig || {} + ); + const [queryResult, setQueryResult] = useState(null); + const [activeTab, setActiveTab] = useState<'query' | 'chart'>('query'); + + // 데이터 소스 변경 처리 + const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => { + setDataSource(newDataSource); + }, []); + + // 차트 설정 변경 처리 + const handleChartConfigChange = useCallback((newConfig: ChartConfig) => { + setChartConfig(newConfig); + }, []); + + // 쿼리 테스트 결과 처리 + const handleQueryTest = useCallback((result: QueryResult) => { + setQueryResult(result); + // 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동 + if (result.rows.length > 0) { + setActiveTab('chart'); + } + }, []); + + // 저장 처리 + const handleSave = useCallback(() => { + const updatedElement: DashboardElement = { + ...element, + dataSource, + chartConfig, + }; + onSave(updatedElement); + onClose(); + }, [element, dataSource, chartConfig, onSave, onClose]); + + // 모달이 열려있지 않으면 렌더링하지 않음 + if (!isOpen) return null; + + return ( +
+
+ {/* 모달 헤더 */} +
+
+

+ {element.title} 설정 +

+

+ 데이터 소스와 차트 설정을 구성하세요 +

+
+ +
+ + {/* 탭 네비게이션 */} +
+ + +
+ + {/* 탭 내용 */} +
+ {activeTab === 'query' && ( + + )} + + {activeTab === 'chart' && ( + + )} +
+ + {/* 모달 푸터 */} +
+
+ {dataSource.query && ( + <> + 💾 쿼리: {dataSource.query.length > 50 + ? `${dataSource.query.substring(0, 50)}...` + : dataSource.query} + + )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx new file mode 100644 index 00000000..d0ac90c2 --- /dev/null +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -0,0 +1,352 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { ChartDataSource, QueryResult } from './types'; + +interface QueryEditorProps { + dataSource?: ChartDataSource; + onDataSourceChange: (dataSource: ChartDataSource) => void; + onQueryTest?: (result: QueryResult) => void; +} + +/** + * SQL 쿼리 에디터 컴포넌트 + * - SQL 쿼리 작성 및 편집 + * - 쿼리 실행 및 결과 미리보기 + * - 데이터 소스 설정 + */ +export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) { + const [query, setQuery] = useState(dataSource?.query || ''); + const [isExecuting, setIsExecuting] = useState(false); + const [queryResult, setQueryResult] = useState(null); + const [error, setError] = useState(null); + + // 쿼리 실행 + const executeQuery = useCallback(async () => { + if (!query.trim()) { + setError('쿼리를 입력해주세요.'); + return; + } + + setIsExecuting(true); + setError(null); + + try { + // 실제 API 호출 대신 샘플 데이터 생성 + await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000)); // 실제 API 호출 시뮬레이션 + + const result: QueryResult = generateSampleQueryResult(query.trim()); + setQueryResult(result); + onQueryTest?.(result); + + // 데이터 소스 업데이트 + onDataSourceChange({ + type: 'database', + query: query.trim(), + refreshInterval: dataSource?.refreshInterval || 30000, + lastExecuted: new Date().toISOString() + }); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.'; + setError(errorMessage); + console.error('Query execution error:', err); + } finally { + setIsExecuting(false); + } + }, [query, dataSource?.refreshInterval, onDataSourceChange, onQueryTest]); + + // 샘플 쿼리 삽입 + const insertSampleQuery = useCallback((sampleType: string) => { + const samples = { + sales: `-- 월별 매출 데이터 +SELECT + DATE_TRUNC('month', order_date) as month, + SUM(total_amount) as sales, + COUNT(*) as order_count +FROM orders +WHERE order_date >= CURRENT_DATE - INTERVAL '12 months' +GROUP BY DATE_TRUNC('month', order_date) +ORDER BY month;`, + + users: `-- 사용자 가입 추이 +SELECT + DATE_TRUNC('week', created_at) as week, + COUNT(*) as new_users +FROM users +WHERE created_at >= CURRENT_DATE - INTERVAL '3 months' +GROUP BY DATE_TRUNC('week', created_at) +ORDER BY week;`, + + products: `-- 상품별 판매량 +SELECT + product_name, + SUM(quantity) as total_sold, + SUM(quantity * price) as revenue +FROM order_items oi +JOIN products p ON oi.product_id = p.id +WHERE oi.created_at >= CURRENT_DATE - INTERVAL '1 month' +GROUP BY product_name +ORDER BY total_sold DESC +LIMIT 10;` + }; + + setQuery(samples[sampleType as keyof typeof samples] || ''); + }, []); + + return ( +
+ {/* 쿼리 에디터 헤더 */} +
+

📝 SQL 쿼리 에디터

+
+ +
+
+ + {/* 샘플 쿼리 버튼들 */} +
+ 샘플 쿼리: + + + +
+ + {/* SQL 쿼리 입력 영역 */} +
+