2025-10-14 17:09:07 +09:00
|
|
|
"use client";
|
2025-10-01 12:06:24 +09:00
|
|
|
|
2025-10-14 17:09:07 +09:00
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
|
|
|
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
|
|
|
|
|
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
|
2025-10-01 12:06:24 +09:00
|
|
|
|
|
|
|
|
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) => {
|
2025-10-14 17:09:07 +09:00
|
|
|
if (!element.dataSource?.query || element.type !== "chart") {
|
2025-10-01 12:06:24 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-14 17:09:07 +09:00
|
|
|
setLoadingElements((prev) => new Set([...prev, element.id]));
|
2025-10-01 12:06:24 +09:00
|
|
|
|
|
|
|
|
try {
|
2025-10-14 17:09:07 +09:00
|
|
|
let result;
|
|
|
|
|
|
|
|
|
|
// 외부 DB vs 현재 DB 분기
|
|
|
|
|
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
|
|
|
|
// 외부 DB
|
|
|
|
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
|
|
|
|
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
|
|
|
|
parseInt(element.dataSource.externalConnectionId),
|
|
|
|
|
element.dataSource.query,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!externalResult.success) {
|
|
|
|
|
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data: QueryResult = {
|
|
|
|
|
columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [],
|
|
|
|
|
rows: externalResult.data || [],
|
|
|
|
|
totalRows: externalResult.data?.length || 0,
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setElementData((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[element.id]: data,
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
// 현재 DB
|
|
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
|
|
|
result = await dashboardApi.executeQuery(element.dataSource.query);
|
|
|
|
|
|
|
|
|
|
const data: QueryResult = {
|
|
|
|
|
columns: result.columns || [],
|
|
|
|
|
rows: result.rows || [],
|
|
|
|
|
totalRows: result.rowCount || 0,
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setElementData((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[element.id]: data,
|
|
|
|
|
}));
|
|
|
|
|
}
|
2025-10-01 12:06:24 +09:00
|
|
|
} catch (error) {
|
2025-10-14 17:09:07 +09:00
|
|
|
// 에러 발생 시 무시 (차트는 빈 상태로 표시됨)
|
2025-10-01 12:06:24 +09:00
|
|
|
} finally {
|
2025-10-14 17:09:07 +09:00
|
|
|
setLoadingElements((prev) => {
|
2025-10-01 12:06:24 +09:00
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
newSet.delete(element.id);
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 모든 요소 데이터 로딩
|
|
|
|
|
const loadAllData = useCallback(async () => {
|
|
|
|
|
setLastRefresh(new Date());
|
2025-10-14 17:09:07 +09:00
|
|
|
|
|
|
|
|
const chartElements = elements.filter((el) => el.type === "chart" && el.dataSource?.query);
|
|
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
// 병렬로 모든 차트 데이터 로딩
|
2025-10-14 17:09:07 +09:00
|
|
|
await Promise.all(chartElements.map((element) => loadElementData(element)));
|
2025-10-01 12:06:24 +09:00
|
|
|
}, [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 (
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="flex h-full items-center justify-center bg-gray-50">
|
2025-10-01 12:06:24 +09:00
|
|
|
<div className="text-center">
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="mb-4 text-6xl">📊</div>
|
|
|
|
|
<div className="mb-2 text-xl font-medium text-gray-700">표시할 요소가 없습니다</div>
|
|
|
|
|
<div className="text-sm text-gray-500">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
2025-10-01 12:06:24 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="relative h-full w-full overflow-auto bg-gray-100">
|
2025-10-01 12:06:24 +09:00
|
|
|
{/* 새로고침 상태 표시 */}
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="text-muted-foreground absolute top-4 right-4 z-10 rounded-lg bg-white px-3 py-2 text-xs shadow-sm">
|
2025-10-01 12:06:24 +09:00
|
|
|
마지막 업데이트: {lastRefresh.toLocaleTimeString()}
|
|
|
|
|
{Array.from(loadingElements).length > 0 && (
|
2025-10-14 17:09:07 +09:00
|
|
|
<span className="text-primary ml-2">({Array.from(loadingElements).length}개 로딩 중...)</span>
|
2025-10-01 12:06:24 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 대시보드 요소들 */}
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="relative" style={{ minHeight: "100%" }}>
|
2025-10-01 12:06:24 +09:00
|
|
|
{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
|
2025-10-14 17:09:07 +09:00
|
|
|
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
2025-10-01 12:06:24 +09:00
|
|
|
style={{
|
|
|
|
|
left: element.position.x,
|
|
|
|
|
top: element.position.y,
|
|
|
|
|
width: element.size.width,
|
2025-10-14 17:09:07 +09:00
|
|
|
height: element.size.height,
|
2025-10-01 12:06:24 +09:00
|
|
|
}}
|
|
|
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
|
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
|
|
|
>
|
|
|
|
|
{/* 헤더 */}
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
|
|
|
|
<h3 className="text-sm font-semibold text-gray-800">{element.title}</h3>
|
|
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
{/* 새로고침 버튼 (호버 시에만 표시) */}
|
|
|
|
|
{isHovered && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onRefresh}
|
|
|
|
|
disabled={isLoading}
|
2025-10-14 17:09:07 +09:00
|
|
|
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
|
2025-10-01 12:06:24 +09:00
|
|
|
title="새로고침"
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? (
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
2025-10-01 12:06:24 +09:00
|
|
|
) : (
|
2025-10-14 17:09:07 +09:00
|
|
|
"🔄"
|
2025-10-01 12:06:24 +09:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 내용 */}
|
|
|
|
|
<div className="h-[calc(100%-57px)]">
|
2025-10-14 17:09:07 +09:00
|
|
|
{element.type === "chart" ? (
|
|
|
|
|
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
|
2025-10-01 12:06:24 +09:00
|
|
|
) : (
|
|
|
|
|
// 위젯 렌더링
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 p-4 text-white">
|
2025-10-01 12:06:24 +09:00
|
|
|
<div className="text-center">
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="mb-2 text-3xl">
|
|
|
|
|
{element.subtype === "exchange" && "💱"}
|
|
|
|
|
{element.subtype === "weather" && "☁️"}
|
2025-10-01 12:06:24 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="text-sm whitespace-pre-line">{element.content}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 로딩 오버레이 */}
|
|
|
|
|
{isLoading && (
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
|
2025-10-01 12:06:24 +09:00
|
|
|
<div className="text-center">
|
2025-10-14 17:09:07 +09:00
|
|
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
|
|
|
|
<div className="text-muted-foreground text-sm">업데이트 중...</div>
|
2025-10-01 12:06:24 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 샘플 쿼리 결과 생성 함수 (뷰어용)
|
|
|
|
|
*/
|
|
|
|
|
function generateSampleQueryResult(query: string, chartType: string): QueryResult {
|
|
|
|
|
// 시간에 따라 약간씩 다른 데이터 생성 (실시간 업데이트 시뮬레이션)
|
|
|
|
|
const timeVariation = Math.sin(Date.now() / 10000) * 0.1 + 1;
|
2025-10-14 17:09:07 +09:00
|
|
|
|
|
|
|
|
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");
|
2025-10-01 12:06:24 +09:00
|
|
|
|
|
|
|
|
let columns: string[];
|
|
|
|
|
let rows: Record<string, any>[];
|
|
|
|
|
|
|
|
|
|
if (isMonthly && isSales) {
|
2025-10-14 17:09:07 +09:00
|
|
|
columns = ["month", "sales", "order_count"];
|
2025-10-01 12:06:24 +09:00
|
|
|
rows = [
|
2025-10-14 17:09:07 +09:00
|
|
|
{ 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) },
|
2025-10-01 12:06:24 +09:00
|
|
|
];
|
|
|
|
|
} else if (isWeekly && isUsers) {
|
2025-10-14 17:09:07 +09:00
|
|
|
columns = ["week", "new_users"];
|
2025-10-01 12:06:24 +09:00
|
|
|
rows = [
|
2025-10-14 17:09:07 +09:00
|
|
|
{ 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) },
|
2025-10-01 12:06:24 +09:00
|
|
|
];
|
|
|
|
|
} else if (isProducts) {
|
2025-10-14 17:09:07 +09:00
|
|
|
columns = ["product_name", "total_sold", "revenue"];
|
2025-10-01 12:06:24 +09:00
|
|
|
rows = [
|
2025-10-14 17:09:07 +09:00
|
|
|
{
|
|
|
|
|
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),
|
|
|
|
|
},
|
2025-10-01 12:06:24 +09:00
|
|
|
];
|
|
|
|
|
} else {
|
2025-10-14 17:09:07 +09:00
|
|
|
columns = ["category", "value", "count"];
|
2025-10-01 12:06:24 +09:00
|
|
|
rows = [
|
2025-10-14 17:09:07 +09:00
|
|
|
{ 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) },
|
2025-10-01 12:06:24 +09:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
columns,
|
|
|
|
|
rows,
|
|
|
|
|
totalRows: rows.length,
|
|
|
|
|
executionTime: Math.floor(Math.random() * 100) + 50,
|
|
|
|
|
};
|
|
|
|
|
}
|