'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 { // console.log('🔄 쿼리 실행 시작:', element.dataSource.query); // 실제 API 호출 const { dashboardApi } = await import('@/lib/api/dashboard'); const result = await dashboardApi.executeQuery(element.dataSource.query); // console.log('✅ 쿼리 실행 결과:', result); setChartData({ columns: result.columns || [], rows: result.rows || [], totalRows: result.rowCount || 0, executionTime: 0 }); } 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 }; }