ERP-node/frontend/components/admin/dashboard/CanvasElement.tsx

399 lines
14 KiB
TypeScript
Raw Normal View History

'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
};
}