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

399 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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-accent0 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-destructive/100 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-primary 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
};
}