2025-10-14 15:25:11 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2025-10-27 14:38:43 +09:00
|
|
|
|
import React, { useEffect, useState, useRef } from "react";
|
2025-10-14 15:25:11 +09:00
|
|
|
|
import { DashboardElement, QueryResult, ChartData } from "../types";
|
|
|
|
|
|
import { Chart } from "./Chart";
|
|
|
|
|
|
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
|
|
|
|
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
|
|
|
|
|
import { dashboardApi } from "@/lib/api/dashboard";
|
2025-10-15 15:05:20 +09:00
|
|
|
|
import { applyQueryFilters } from "../utils/queryHelpers";
|
2025-10-29 11:52:18 +09:00
|
|
|
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
|
|
|
|
|
interface ChartRendererProps {
|
|
|
|
|
|
element: DashboardElement;
|
|
|
|
|
|
data?: QueryResult;
|
|
|
|
|
|
width?: number;
|
|
|
|
|
|
height?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-14 15:25:11 +09:00
|
|
|
|
* 차트 렌더러 컴포넌트 (D3 기반)
|
|
|
|
|
|
* - 데이터 소스에서 데이터 페칭
|
|
|
|
|
|
* - QueryResult를 ChartData로 변환
|
|
|
|
|
|
* - D3 Chart 컴포넌트에 전달
|
2025-09-30 13:23:22 +09:00
|
|
|
|
*/
|
2025-10-27 14:38:43 +09:00
|
|
|
|
export function ChartRenderer({ element, data, width, height = 200 }: ChartRendererProps) {
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const [containerWidth, setContainerWidth] = useState(width || 250);
|
2025-10-14 15:25:11 +09:00
|
|
|
|
const [chartData, setChartData] = useState<ChartData | null>(null);
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
2025-10-27 14:38:43 +09:00
|
|
|
|
// 컨테이너 너비 측정 (width가 undefined일 때)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (width !== undefined) {
|
|
|
|
|
|
setContainerWidth(width);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateWidth = () => {
|
|
|
|
|
|
if (containerRef.current) {
|
|
|
|
|
|
const measuredWidth = containerRef.current.offsetWidth;
|
|
|
|
|
|
console.log("📏 컨테이너 너비 측정:", measuredWidth);
|
|
|
|
|
|
setContainerWidth(measuredWidth || 500); // 기본값 500
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 약간의 지연을 두고 측정 (DOM 렌더링 완료 후)
|
|
|
|
|
|
const timer = setTimeout(updateWidth, 100);
|
|
|
|
|
|
updateWidth();
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener("resize", updateWidth);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
window.removeEventListener("resize", updateWidth);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [width]);
|
|
|
|
|
|
|
2025-10-14 15:25:11 +09:00
|
|
|
|
// 데이터 페칭
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
|
// 이미 data가 전달된 경우 사용
|
|
|
|
|
|
if (data) {
|
|
|
|
|
|
const transformed = transformQueryResultToChartData(data, element.chartConfig || {});
|
|
|
|
|
|
setChartData(transformed);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터 소스가 설정되어 있으면 페칭
|
2025-10-14 17:54:28 +09:00
|
|
|
|
if (element.dataSource && element.chartConfig) {
|
2025-10-14 15:25:11 +09:00
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
let queryResult: QueryResult;
|
|
|
|
|
|
|
2025-10-14 17:54:28 +09:00
|
|
|
|
// REST API vs Database 분기
|
|
|
|
|
|
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
2025-10-15 10:02:32 +09:00
|
|
|
|
// REST API - 백엔드 프록시를 통한 호출 (CORS 우회)
|
2025-10-14 17:54:28 +09:00
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
if (element.dataSource.queryParams) {
|
|
|
|
|
|
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
|
|
|
|
|
if (key && value) {
|
2025-10-21 17:18:28 +09:00
|
|
|
|
params.append(key, String(value));
|
2025-10-14 17:54:28 +09:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-14 15:25:11 +09:00
|
|
|
|
|
2025-10-29 11:52:18 +09:00
|
|
|
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
2025-10-15 10:02:32 +09:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
url: element.dataSource.endpoint,
|
|
|
|
|
|
method: "GET",
|
|
|
|
|
|
headers: element.dataSource.headers || {},
|
|
|
|
|
|
queryParams: Object.fromEntries(params),
|
|
|
|
|
|
}),
|
2025-10-14 17:54:28 +09:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 10:02:32 +09:00
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
|
throw new Error(result.message || "외부 API 호출 실패");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const apiData = result.data;
|
2025-10-14 17:54:28 +09:00
|
|
|
|
|
|
|
|
|
|
// JSON Path 처리
|
|
|
|
|
|
let processedData = apiData;
|
|
|
|
|
|
if (element.dataSource.jsonPath) {
|
|
|
|
|
|
const paths = element.dataSource.jsonPath.split(".");
|
|
|
|
|
|
for (const path of paths) {
|
|
|
|
|
|
if (processedData && typeof processedData === "object" && path in processedData) {
|
|
|
|
|
|
processedData = processedData[path];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
|
|
|
|
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
|
|
|
|
|
2025-10-14 15:25:11 +09:00
|
|
|
|
queryResult = {
|
2025-10-14 17:54:28 +09:00
|
|
|
|
columns,
|
|
|
|
|
|
rows,
|
|
|
|
|
|
totalRows: rows.length,
|
2025-10-14 15:25:11 +09:00
|
|
|
|
executionTime: 0,
|
|
|
|
|
|
};
|
2025-10-14 17:54:28 +09:00
|
|
|
|
} else if (element.dataSource.query) {
|
|
|
|
|
|
// Database (현재 DB 또는 외부 DB)
|
|
|
|
|
|
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
2025-10-15 15:05:20 +09:00
|
|
|
|
// 외부 DB - 필터 적용
|
|
|
|
|
|
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
2025-10-14 17:54:28 +09:00
|
|
|
|
const result = await ExternalDbConnectionAPI.executeQuery(
|
|
|
|
|
|
parseInt(element.dataSource.externalConnectionId),
|
2025-10-15 15:05:20 +09:00
|
|
|
|
filteredQuery,
|
2025-10-14 17:54:28 +09:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
|
throw new Error(result.message || "외부 DB 쿼리 실행 실패");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
queryResult = {
|
|
|
|
|
|
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
|
|
|
|
|
|
rows: result.data || [],
|
|
|
|
|
|
totalRows: result.data?.length || 0,
|
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
} else {
|
2025-10-15 15:05:20 +09:00
|
|
|
|
// 현재 DB - 필터 적용
|
|
|
|
|
|
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
|
|
|
|
|
const result = await dashboardApi.executeQuery(filteredQuery);
|
2025-10-14 17:54:28 +09:00
|
|
|
|
queryResult = {
|
|
|
|
|
|
columns: result.columns,
|
|
|
|
|
|
rows: result.rows,
|
|
|
|
|
|
totalRows: result.rowCount,
|
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
2025-10-14 15:25:11 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ChartData로 변환
|
|
|
|
|
|
const transformed = transformQueryResultToChartData(queryResult, element.chartConfig);
|
|
|
|
|
|
setChartData(transformed);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const errorMessage = err instanceof Error ? err.message : "데이터 로딩 실패";
|
|
|
|
|
|
setError(errorMessage);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
fetchData();
|
|
|
|
|
|
|
|
|
|
|
|
// 자동 새로고침 설정 (0이면 수동이므로 interval 설정 안 함)
|
|
|
|
|
|
const refreshInterval = element.dataSource?.refreshInterval;
|
|
|
|
|
|
if (refreshInterval && refreshInterval > 0) {
|
|
|
|
|
|
const interval = setInterval(fetchData, refreshInterval);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}
|
2025-10-21 17:18:28 +09:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2025-10-14 15:25:11 +09:00
|
|
|
|
}, [
|
|
|
|
|
|
element.dataSource?.query,
|
|
|
|
|
|
element.dataSource?.connectionType,
|
|
|
|
|
|
element.dataSource?.externalConnectionId,
|
|
|
|
|
|
element.dataSource?.refreshInterval,
|
2025-10-21 17:18:28 +09:00
|
|
|
|
element.dataSource?.type,
|
|
|
|
|
|
element.dataSource?.endpoint,
|
|
|
|
|
|
element.dataSource?.jsonPath,
|
2025-10-14 15:25:11 +09:00
|
|
|
|
element.chartConfig,
|
|
|
|
|
|
data,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 로딩 중
|
|
|
|
|
|
if (isLoading) {
|
2025-09-30 13:23:22 +09:00
|
|
|
|
return (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
2025-09-30 13:23:22 +09:00
|
|
|
|
<div className="text-center">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
2025-10-14 15:25:11 +09:00
|
|
|
|
<div className="text-sm">데이터 로딩 중...</div>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-14 15:25:11 +09:00
|
|
|
|
// 에러
|
|
|
|
|
|
if (error) {
|
2025-09-30 13:23:22 +09:00
|
|
|
|
return (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="flex h-full w-full items-center justify-center text-destructive">
|
2025-09-30 13:23:22 +09:00
|
|
|
|
<div className="text-center">
|
2025-10-14 15:25:11 +09:00
|
|
|
|
<div className="mb-2 text-2xl">⚠️</div>
|
|
|
|
|
|
<div className="text-sm font-medium">오류 발생</div>
|
|
|
|
|
|
<div className="mt-1 text-xs">{error}</div>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-14 15:25:11 +09:00
|
|
|
|
// 데이터나 설정이 없으면
|
2025-10-15 10:02:32 +09:00
|
|
|
|
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
|
|
|
|
|
const isApiSource = element.dataSource?.type === "api";
|
|
|
|
|
|
const needsYAxis = !(isPieChart || isApiSource) || (!element.chartConfig?.aggregation && !element.chartConfig?.yAxis);
|
|
|
|
|
|
|
|
|
|
|
|
if (!chartData || !element.chartConfig?.xAxis || (needsYAxis && !element.chartConfig?.yAxis)) {
|
2025-10-14 15:25:11 +09:00
|
|
|
|
return (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
2025-10-14 15:25:11 +09:00
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-sm">데이터를 설정해주세요</div>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
</div>
|
2025-10-14 15:25:11 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}
|
2025-10-14 15:25:11 +09:00
|
|
|
|
|
|
|
|
|
|
// D3 차트 렌더링
|
2025-10-27 14:38:43 +09:00
|
|
|
|
const actualWidth = width !== undefined ? width : containerWidth;
|
|
|
|
|
|
|
2025-10-28 15:02:37 +09:00
|
|
|
|
// 최소 크기 제약 완화 (1x1 위젯 지원)
|
2025-10-27 14:38:43 +09:00
|
|
|
|
const isCircularChart = element.subtype === "pie" || element.subtype === "donut";
|
2025-10-28 15:02:37 +09:00
|
|
|
|
const minWidth = 35; // 최소 너비 35px
|
|
|
|
|
|
const finalWidth = Math.max(actualWidth - 4, minWidth);
|
|
|
|
|
|
// 최소 높이도 35px로 설정
|
|
|
|
|
|
const finalHeight = Math.max(height - (isCircularChart ? 10 : 4), 35);
|
2025-10-27 14:38:43 +09:00
|
|
|
|
|
|
|
|
|
|
console.log("🎨 ChartRenderer:", {
|
|
|
|
|
|
elementSubtype: element.subtype,
|
|
|
|
|
|
propWidth: width,
|
|
|
|
|
|
containerWidth,
|
|
|
|
|
|
actualWidth,
|
|
|
|
|
|
finalWidth,
|
|
|
|
|
|
finalHeight,
|
|
|
|
|
|
hasChartData: !!chartData,
|
|
|
|
|
|
chartDataLabels: chartData?.labels,
|
|
|
|
|
|
chartDataDatasets: chartData?.datasets?.length,
|
|
|
|
|
|
isCircularChart,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-14 15:25:11 +09:00
|
|
|
|
return (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-background p-0.5">
|
2025-10-27 14:38:43 +09:00
|
|
|
|
<div className="flex items-center justify-center">
|
|
|
|
|
|
<Chart
|
|
|
|
|
|
chartType={element.subtype}
|
|
|
|
|
|
data={chartData}
|
|
|
|
|
|
config={element.chartConfig}
|
|
|
|
|
|
width={finalWidth}
|
|
|
|
|
|
height={finalHeight}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-10-14 15:25:11 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}
|