ERP-node/frontend/components/dashboard/DashboardViewer.tsx

278 lines
10 KiB
TypeScript

'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { DashboardElement, QueryResult } from '@/components/admin/dashboard/types';
import { ChartRenderer } from '@/components/admin/dashboard/charts/ChartRenderer';
interface DashboardViewerProps {
elements: DashboardElement[];
dashboardId: string;
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
}
/**
* 대시보드 뷰어 컴포넌트
* - 저장된 대시보드를 읽기 전용으로 표시
* - 실시간 데이터 업데이트
* - 반응형 레이아웃
*/
export function DashboardViewer({ elements, dashboardId, refreshInterval }: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
// 개별 요소 데이터 로딩
const loadElementData = useCallback(async (element: DashboardElement) => {
if (!element.dataSource?.query || element.type !== 'chart') {
return;
}
setLoadingElements(prev => new Set([...prev, element.id]));
try {
// console.log(`🔄 요소 ${element.id} 데이터 로딩 시작:`, element.dataSource.query);
// 실제 API 호출
const { dashboardApi } = await import('@/lib/api/dashboard');
const result = await dashboardApi.executeQuery(element.dataSource.query);
// console.log(`✅ 요소 ${element.id} 데이터 로딩 완료:`, result);
const data: QueryResult = {
columns: result.columns || [],
rows: result.rows || [],
totalRows: result.rowCount || 0,
executionTime: 0
};
setElementData(prev => ({
...prev,
[element.id]: data
}));
} catch (error) {
// console.error(`❌ Element ${element.id} data loading error:`, error);
} finally {
setLoadingElements(prev => {
const newSet = new Set(prev);
newSet.delete(element.id);
return newSet;
});
}
}, []);
// 모든 요소 데이터 로딩
const loadAllData = useCallback(async () => {
setLastRefresh(new Date());
const chartElements = elements.filter(el => el.type === 'chart' && el.dataSource?.query);
// 병렬로 모든 차트 데이터 로딩
await Promise.all(chartElements.map(element => loadElementData(element)));
}, [elements, loadElementData]);
// 초기 데이터 로딩
useEffect(() => {
loadAllData();
}, [loadAllData]);
// 전체 새로고침 간격 설정
useEffect(() => {
if (!refreshInterval || refreshInterval === 0) {
return;
}
const interval = setInterval(loadAllData, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, loadAllData]);
// 요소가 없는 경우
if (elements.length === 0) {
return (
<div className="h-full flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">📊</div>
<div className="text-xl font-medium text-gray-700 mb-2">
</div>
<div className="text-sm text-gray-500">
</div>
</div>
</div>
);
}
return (
<div className="relative w-full h-full bg-gray-100 overflow-auto">
{/* 새로고침 상태 표시 */}
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-gray-600">
: {lastRefresh.toLocaleTimeString()}
{Array.from(loadingElements).length > 0 && (
<span className="ml-2 text-blue-600">
({Array.from(loadingElements).length} ...)
</span>
)}
</div>
{/* 대시보드 요소들 */}
<div className="relative" style={{ minHeight: '100%' }}>
{elements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
</div>
</div>
);
}
interface ViewerElementProps {
element: DashboardElement;
data?: QueryResult;
isLoading: boolean;
onRefresh: () => void;
}
/**
* 개별 뷰어 요소 컴포넌트
*/
function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="absolute bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
style={{
left: element.position.x,
top: element.position.y,
width: element.size.width,
height: element.size.height
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 헤더 */}
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="font-semibold text-gray-800 text-sm">{element.title}</h3>
{/* 새로고침 버튼 (호버 시에만 표시) */}
{isHovered && (
<button
onClick={onRefresh}
disabled={isLoading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="w-4 h-4 border border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
'🔄'
)}
</button>
)}
</div>
{/* 내용 */}
<div className="h-[calc(100%-57px)]">
{element.type === 'chart' ? (
<ChartRenderer
element={element}
data={data}
width={element.size.width}
height={element.size.height - 57}
/>
) : (
// 위젯 렌더링
<div className="w-full h-full p-4 flex items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 text-white">
<div className="text-center">
<div className="text-3xl mb-2">
{element.subtype === 'exchange' && '💱'}
{element.subtype === 'weather' && '☁️'}
</div>
<div className="text-sm whitespace-pre-line">{element.content}</div>
</div>
</div>
)}
</div>
{/* 로딩 오버레이 */}
{isLoading && (
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center">
<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 text-gray-600"> ...</div>
</div>
</div>
)}
</div>
);
}
/**
* 샘플 쿼리 결과 생성 함수 (뷰어용)
*/
function generateSampleQueryResult(query: string, chartType: string): QueryResult {
// 시간에 따라 약간씩 다른 데이터 생성 (실시간 업데이트 시뮬레이션)
const timeVariation = Math.sin(Date.now() / 10000) * 0.1 + 1;
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('상품');
const isWeekly = query.toLowerCase().includes('week');
let columns: string[];
let rows: Record<string, any>[];
if (isMonthly && isSales) {
columns = ['month', 'sales', 'order_count'];
rows = [
{ month: '2024-01', sales: Math.round(1200000 * timeVariation), order_count: Math.round(45 * timeVariation) },
{ month: '2024-02', sales: Math.round(1350000 * timeVariation), order_count: Math.round(52 * timeVariation) },
{ month: '2024-03', sales: Math.round(1180000 * timeVariation), order_count: Math.round(41 * timeVariation) },
{ month: '2024-04', sales: Math.round(1420000 * timeVariation), order_count: Math.round(58 * timeVariation) },
{ month: '2024-05', sales: Math.round(1680000 * timeVariation), order_count: Math.round(67 * timeVariation) },
{ month: '2024-06', sales: Math.round(1540000 * timeVariation), order_count: Math.round(61 * timeVariation) },
];
} else if (isWeekly && isUsers) {
columns = ['week', 'new_users'];
rows = [
{ week: '2024-W10', new_users: Math.round(23 * timeVariation) },
{ week: '2024-W11', new_users: Math.round(31 * timeVariation) },
{ week: '2024-W12', new_users: Math.round(28 * timeVariation) },
{ week: '2024-W13', new_users: Math.round(35 * timeVariation) },
{ week: '2024-W14', new_users: Math.round(42 * timeVariation) },
{ week: '2024-W15', new_users: Math.round(38 * timeVariation) },
];
} else if (isProducts) {
columns = ['product_name', 'total_sold', 'revenue'];
rows = [
{ product_name: '스마트폰', total_sold: Math.round(156 * timeVariation), revenue: Math.round(234000000 * timeVariation) },
{ product_name: '노트북', total_sold: Math.round(89 * timeVariation), revenue: Math.round(178000000 * timeVariation) },
{ product_name: '태블릿', total_sold: Math.round(134 * timeVariation), revenue: Math.round(67000000 * timeVariation) },
{ product_name: '이어폰', total_sold: Math.round(267 * timeVariation), revenue: Math.round(26700000 * timeVariation) },
{ product_name: '스마트워치', total_sold: Math.round(98 * timeVariation), revenue: Math.round(49000000 * timeVariation) },
];
} else {
columns = ['category', 'value', 'count'];
rows = [
{ category: 'A', value: Math.round(100 * timeVariation), count: Math.round(10 * timeVariation) },
{ category: 'B', value: Math.round(150 * timeVariation), count: Math.round(15 * timeVariation) },
{ category: 'C', value: Math.round(120 * timeVariation), count: Math.round(12 * timeVariation) },
{ category: 'D', value: Math.round(180 * timeVariation), count: Math.round(18 * timeVariation) },
{ category: 'E', value: Math.round(90 * timeVariation), count: Math.round(9 * timeVariation) },
];
}
return {
columns,
rows,
totalRows: rows.length,
executionTime: Math.floor(Math.random() * 100) + 50,
};
}