399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
'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<DashboardElement>) => 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<QueryResult | null>(null);
|
||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||
const elementRef = useRef<HTMLDivElement>(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 (
|
||
<div
|
||
ref={elementRef}
|
||
className={`
|
||
absolute bg-white border-2 rounded-lg shadow-lg
|
||
min-w-[150px] min-h-[150px] cursor-move
|
||
${isSelected ? 'border-green-500 shadow-green-200' : 'border-gray-600'}
|
||
`}
|
||
style={{
|
||
left: element.position.x,
|
||
top: element.position.y,
|
||
width: element.size.width,
|
||
height: element.size.height
|
||
}}
|
||
onMouseDown={handleMouseDown}
|
||
>
|
||
{/* 헤더 */}
|
||
<div className="bg-gray-50 p-3 border-b border-gray-200 flex justify-between items-center cursor-move">
|
||
<span className="font-bold text-sm text-gray-800">{element.title}</span>
|
||
<div className="flex gap-1">
|
||
{/* 설정 버튼 */}
|
||
{onConfigure && (
|
||
<button
|
||
className="
|
||
w-6 h-6 flex items-center justify-center
|
||
text-gray-400 hover:bg-blue-500 hover:text-white
|
||
rounded transition-colors duration-200
|
||
"
|
||
onClick={() => onConfigure(element)}
|
||
title="설정"
|
||
>
|
||
⚙️
|
||
</button>
|
||
)}
|
||
{/* 삭제 버튼 */}
|
||
<button
|
||
className="
|
||
element-close w-6 h-6 flex items-center justify-center
|
||
text-gray-400 hover:bg-red-500 hover:text-white
|
||
rounded transition-colors duration-200
|
||
"
|
||
onClick={handleRemove}
|
||
title="삭제"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 내용 */}
|
||
<div className="h-[calc(100%-45px)] relative">
|
||
{element.type === 'chart' ? (
|
||
// 차트 렌더링
|
||
<div className="w-full h-full bg-white">
|
||
{isLoadingData ? (
|
||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
||
<div className="text-center">
|
||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||
<div className="text-sm">데이터 로딩 중...</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<ChartRenderer
|
||
element={element}
|
||
data={chartData}
|
||
width={element.size.width}
|
||
height={element.size.height - 45}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : (
|
||
// 위젯 렌더링 (기존 방식)
|
||
<div className={`
|
||
w-full h-full p-5 flex items-center justify-center
|
||
text-sm text-white font-medium text-center
|
||
${getContentClass()}
|
||
`}>
|
||
<div>
|
||
<div className="text-4xl mb-2">
|
||
{element.type === 'widget' && element.subtype === 'exchange' && '💱'}
|
||
{element.type === 'widget' && element.subtype === 'weather' && '☁️'}
|
||
</div>
|
||
<div className="whitespace-pre-line">{element.content}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 리사이즈 핸들 (선택된 요소에만 표시) */}
|
||
{isSelected && (
|
||
<>
|
||
<ResizeHandle position="nw" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle position="ne" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle position="sw" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle position="se" onMouseDown={handleResizeMouseDown} />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={`
|
||
resize-handle absolute w-3 h-3 bg-green-500 border border-white
|
||
${getPositionClass()}
|
||
`}
|
||
onMouseDown={(e) => 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<string, any>[];
|
||
|
||
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
|
||
};
|
||
}
|