ERP-node/frontend/components/admin/dashboard/charts/ChartRenderer.tsx

280 lines
9.7 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.

"use client";
import React, { useEffect, useState, useRef } from "react";
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";
import { applyQueryFilters } from "../utils/queryHelpers";
import { getApiUrl } from "@/lib/utils/apiUrl";
interface ChartRendererProps {
element: DashboardElement;
data?: QueryResult;
width?: number;
height?: number;
}
/**
* 차트 렌더러 컴포넌트 (D3 기반)
* - 데이터 소스에서 데이터 페칭
* - QueryResult를 ChartData로 변환
* - D3 Chart 컴포넌트에 전달
*/
export function ChartRenderer({ element, data, width, height = 200 }: ChartRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(width || 250);
const [chartData, setChartData] = useState<ChartData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 컨테이너 너비 측정 (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]);
// 데이터 페칭
useEffect(() => {
const fetchData = async () => {
// 이미 data가 전달된 경우 사용
if (data) {
const transformed = transformQueryResultToChartData(data, element.chartConfig || {});
setChartData(transformed);
return;
}
// 데이터 소스가 설정되어 있으면 페칭
if (element.dataSource && element.chartConfig) {
setIsLoading(true);
setError(null);
try {
let queryResult: QueryResult;
// REST API vs Database 분기
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
// REST API - 백엔드 프록시를 통한 호출 (CORS 우회)
const params = new URLSearchParams();
if (element.dataSource.queryParams) {
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
if (key && value) {
params.append(key, String(value));
}
});
}
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: element.dataSource.endpoint,
method: "GET",
headers: element.dataSource.headers || {},
queryParams: Object.fromEntries(params),
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "외부 API 호출 실패");
}
const apiData = result.data;
// 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]) : [];
queryResult = {
columns,
rows,
totalRows: rows.length,
executionTime: 0,
};
} else if (element.dataSource.query) {
// Database (현재 DB 또는 외부 DB)
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
// 외부 DB - 필터 적용
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
const result = await ExternalDbConnectionAPI.executeQuery(
parseInt(element.dataSource.externalConnectionId),
filteredQuery,
);
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 {
// 현재 DB - 필터 적용
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
const result = await dashboardApi.executeQuery(filteredQuery);
queryResult = {
columns: result.columns,
rows: result.rows,
totalRows: result.rowCount,
executionTime: 0,
};
}
} else {
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
}
// 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);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
element.dataSource?.query,
element.dataSource?.connectionType,
element.dataSource?.externalConnectionId,
element.dataSource?.refreshInterval,
element.dataSource?.type,
element.dataSource?.endpoint,
element.dataSource?.jsonPath,
element.chartConfig,
data,
]);
// 로딩 중
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<div className="text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div className="text-sm"> ...</div>
</div>
</div>
);
}
// 에러
if (error) {
return (
<div className="flex h-full w-full items-center justify-center text-destructive">
<div className="text-center">
<div className="mb-2 text-2xl"></div>
<div className="text-sm font-medium"> </div>
<div className="mt-1 text-xs">{error}</div>
</div>
</div>
);
}
// 데이터나 설정이 없으면
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)) {
return (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<div className="text-center">
<div className="text-sm"> </div>
</div>
</div>
);
}
// D3 차트 렌더링
const actualWidth = width !== undefined ? width : containerWidth;
// 최소 크기 제약 완화 (1x1 위젯 지원)
const isCircularChart = element.subtype === "pie" || element.subtype === "donut";
const minWidth = 35; // 최소 너비 35px
const finalWidth = Math.max(actualWidth - 4, minWidth);
// 최소 높이도 35px로 설정
const finalHeight = Math.max(height - (isCircularChart ? 10 : 4), 35);
console.log("🎨 ChartRenderer:", {
elementSubtype: element.subtype,
propWidth: width,
containerWidth,
actualWidth,
finalWidth,
finalHeight,
hasChartData: !!chartData,
chartDataLabels: chartData?.labels,
chartDataDatasets: chartData?.datasets?.length,
isCircularChart,
});
return (
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-background p-0.5">
<div className="flex items-center justify-center">
<Chart
chartType={element.subtype}
data={chartData}
config={element.chartConfig}
width={finalWidth}
height={finalHeight}
/>
</div>
</div>
);
}