379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
import { DashboardElement, ChartDataSource, ChartData } from "@/components/admin/dashboard/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Loader2, RefreshCw } from "lucide-react";
|
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
|
import { Chart } from "@/components/admin/dashboard/charts/Chart";
|
|
|
|
interface ChartTestWidgetProps {
|
|
element: DashboardElement;
|
|
}
|
|
|
|
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
|
|
|
|
export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [containerSize, setContainerSize] = useState({ width: 600, height: 400 });
|
|
|
|
// console.log("🧪 ChartTestWidget 렌더링 (D3 기반)!", element);
|
|
|
|
const dataSources = useMemo(() => {
|
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
|
|
|
// 컨테이너 크기 측정
|
|
useEffect(() => {
|
|
const updateSize = () => {
|
|
if (containerRef.current) {
|
|
const width = containerRef.current.offsetWidth || 600;
|
|
const height = containerRef.current.offsetHeight || 400;
|
|
setContainerSize({ width, height });
|
|
}
|
|
};
|
|
|
|
updateSize();
|
|
window.addEventListener("resize", updateSize);
|
|
return () => window.removeEventListener("resize", updateSize);
|
|
}, []);
|
|
|
|
// 다중 데이터 소스 로딩
|
|
const loadMultipleDataSources = useCallback(async () => {
|
|
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
|
|
|
if (!dataSources || dataSources.length === 0) {
|
|
// console.log("⚠️ 데이터 소스가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
dataSources.map(async (source) => {
|
|
try {
|
|
// console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
|
|
|
if (source.type === "api") {
|
|
return await loadRestApiData(source);
|
|
} else if (source.type === "database") {
|
|
return await loadDatabaseData(source);
|
|
}
|
|
|
|
return [];
|
|
} catch (err: any) {
|
|
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
|
return [];
|
|
}
|
|
}),
|
|
);
|
|
|
|
const allData: any[] = [];
|
|
results.forEach((result, index) => {
|
|
if (result.status === "fulfilled" && Array.isArray(result.value)) {
|
|
const sourceData = result.value.map((item: any) => ({
|
|
...item,
|
|
_source: dataSources[index].name || dataSources[index].id || `소스 ${index + 1}`,
|
|
}));
|
|
allData.push(...sourceData);
|
|
}
|
|
});
|
|
|
|
// console.log(`✅ 총 ${allData.length}개의 데이터 로딩 완료`);
|
|
setData(allData);
|
|
setLastRefreshTime(new Date());
|
|
} catch (err: any) {
|
|
console.error("❌ 데이터 로딩 중 오류:", err);
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
|
|
|
// 수동 새로고침
|
|
const handleManualRefresh = useCallback(() => {
|
|
// console.log("🔄 수동 새로고침 버튼 클릭");
|
|
loadMultipleDataSources();
|
|
}, [loadMultipleDataSources]);
|
|
|
|
// REST API 데이터 로딩
|
|
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
|
if (!source.endpoint) {
|
|
throw new Error("API endpoint가 없습니다.");
|
|
}
|
|
|
|
const queryParams: Record<string, string> = {};
|
|
if (source.queryParams) {
|
|
source.queryParams.forEach((param) => {
|
|
if (param.key && param.value) {
|
|
queryParams[param.key] = param.value;
|
|
}
|
|
});
|
|
}
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (source.headers) {
|
|
source.headers.forEach((header) => {
|
|
if (header.key && header.value) {
|
|
headers[header.key] = header.value;
|
|
}
|
|
});
|
|
}
|
|
|
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
url: source.endpoint,
|
|
method: source.method || "GET",
|
|
headers,
|
|
queryParams,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API 호출 실패: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (!result.success) {
|
|
throw new Error(result.message || "API 호출 실패");
|
|
}
|
|
|
|
let apiData = result.data;
|
|
if (source.jsonPath) {
|
|
const pathParts = source.jsonPath.split(".");
|
|
for (const part of pathParts) {
|
|
apiData = apiData?.[part];
|
|
}
|
|
}
|
|
|
|
const rows = Array.isArray(apiData) ? apiData : [apiData];
|
|
return applyColumnMapping(rows, source.columnMapping);
|
|
};
|
|
|
|
// Database 데이터 로딩
|
|
const loadDatabaseData = async (source: ChartDataSource): Promise<any[]> => {
|
|
if (!source.query) {
|
|
throw new Error("SQL 쿼리가 없습니다.");
|
|
}
|
|
|
|
let result;
|
|
if (source.connectionType === "external" && source.externalConnectionId) {
|
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
|
result = await ExternalDbConnectionAPI.executeQuery(parseInt(source.externalConnectionId), source.query);
|
|
} else {
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
|
|
try {
|
|
const queryResult = await dashboardApi.executeQuery(source.query);
|
|
result = {
|
|
success: true,
|
|
rows: queryResult.rows || [],
|
|
};
|
|
} catch (err: any) {
|
|
console.error("❌ 내부 DB 쿼리 실패:", err);
|
|
throw new Error(err.message || "쿼리 실패");
|
|
}
|
|
}
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.message || "쿼리 실패");
|
|
}
|
|
|
|
const rows = result.rows || result.data || [];
|
|
return applyColumnMapping(rows, source.columnMapping);
|
|
};
|
|
|
|
// 초기 로드
|
|
useEffect(() => {
|
|
if (dataSources && dataSources.length > 0) {
|
|
loadMultipleDataSources();
|
|
}
|
|
}, [dataSources, loadMultipleDataSources]);
|
|
|
|
// 자동 새로고침
|
|
useEffect(() => {
|
|
if (!dataSources || dataSources.length === 0) return;
|
|
|
|
const intervals = dataSources
|
|
.map((ds) => ds.refreshInterval)
|
|
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
|
|
|
if (intervals.length === 0) return;
|
|
|
|
const minInterval = Math.min(...intervals);
|
|
// console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
|
|
|
const intervalId = setInterval(() => {
|
|
// console.log("🔄 자동 새로고침 실행");
|
|
loadMultipleDataSources();
|
|
}, minInterval * 1000);
|
|
|
|
return () => {
|
|
// console.log("⏹️ 자동 새로고침 정리");
|
|
clearInterval(intervalId);
|
|
};
|
|
}, [dataSources, loadMultipleDataSources]);
|
|
|
|
const chartConfig = element?.chartConfig || {};
|
|
const chartType = chartConfig.chartType || "line";
|
|
const mergeMode = chartConfig.mergeMode || false;
|
|
const dataSourceConfigs = chartConfig.dataSourceConfigs || [];
|
|
|
|
// 데이터를 D3 Chart 컴포넌트 형식으로 변환
|
|
const chartData = useMemo((): ChartData | null => {
|
|
if (data.length === 0 || dataSourceConfigs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const labels = new Set<string>();
|
|
const datasets: any[] = [];
|
|
|
|
// 병합 모드: 여러 데이터 소스를 하나로 합침
|
|
if (mergeMode && dataSourceConfigs.length > 1) {
|
|
// console.log("🔀 병합 모드 활성화");
|
|
|
|
// 모든 데이터 소스의 X축 필드 수집 (첫 번째 데이터 소스의 X축 사용)
|
|
const baseConfig = dataSourceConfigs[0];
|
|
const xAxisField = baseConfig.xAxis;
|
|
|
|
// console.log("📊 X축 필드:", xAxisField);
|
|
|
|
// X축 값 수집 (모든 데이터 소스에서)
|
|
dataSourceConfigs.forEach((dsConfig, idx) => {
|
|
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
|
const sourceData = data.filter((item) => item._source === sourceName);
|
|
// console.log(` 소스 ${idx + 1} (${sourceName}): ${sourceData.length}개 행`);
|
|
|
|
sourceData.forEach((item) => {
|
|
if (item[xAxisField] !== undefined) {
|
|
labels.add(String(item[xAxisField]));
|
|
}
|
|
});
|
|
});
|
|
|
|
// console.log("📍 수집된 X축 라벨:", Array.from(labels));
|
|
|
|
// 각 데이터 소스별로 데이터셋 생성 (병합하지 않고 각각 표시)
|
|
dataSourceConfigs.forEach((dsConfig, idx) => {
|
|
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${idx + 1}`;
|
|
const sourceData = data.filter((item) => item._source === sourceName);
|
|
|
|
// 각 Y축 필드마다 데이터셋 생성
|
|
(dsConfig.yAxis || []).forEach((yAxisField, yIdx) => {
|
|
const datasetData: number[] = [];
|
|
|
|
labels.forEach((label) => {
|
|
const matchingItem = sourceData.find((item) => String(item[xAxisField]) === label);
|
|
const value = matchingItem && yAxisField ? parseFloat(matchingItem[yAxisField]) || 0 : 0;
|
|
datasetData.push(value);
|
|
});
|
|
|
|
// console.log(` 📈 ${sourceName} - ${yAxisField}: [${datasetData.join(", ")}]`);
|
|
|
|
datasets.push({
|
|
label: `${sourceName} - ${yAxisField}`,
|
|
data: datasetData,
|
|
backgroundColor: COLORS[(idx * 2 + yIdx) % COLORS.length],
|
|
borderColor: COLORS[(idx * 2 + yIdx) % COLORS.length],
|
|
type: dsConfig.chartType || chartType, // 데이터 소스별 차트 타입
|
|
});
|
|
});
|
|
});
|
|
|
|
// console.log("✅ 병합 모드 데이터셋 생성 완료:", datasets.length, "개");
|
|
} else {
|
|
// 일반 모드: 각 데이터 소스를 별도로 표시
|
|
dataSourceConfigs.forEach((dsConfig, index) => {
|
|
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
|
const sourceData = data.filter((item) => item._source === sourceName);
|
|
|
|
// X축 값 수집
|
|
sourceData.forEach((item) => {
|
|
const xValue = item[dsConfig.xAxis];
|
|
if (xValue !== undefined) {
|
|
labels.add(String(xValue));
|
|
}
|
|
});
|
|
|
|
// Y축 데이터 수집
|
|
const yField = dsConfig.yAxis[0];
|
|
const dataValues: number[] = [];
|
|
|
|
labels.forEach((label) => {
|
|
const matchingItem = sourceData.find((item) => String(item[dsConfig.xAxis]) === label);
|
|
dataValues.push(matchingItem && yField ? parseFloat(matchingItem[yField]) || 0 : 0);
|
|
});
|
|
|
|
datasets.push({
|
|
label: dsConfig.label || sourceName,
|
|
data: dataValues,
|
|
color: COLORS[index % COLORS.length],
|
|
});
|
|
});
|
|
}
|
|
|
|
return {
|
|
labels: Array.from(labels),
|
|
datasets,
|
|
};
|
|
}, [data, dataSourceConfigs, mergeMode, dataSources]);
|
|
|
|
return (
|
|
<div className="flex h-full w-full flex-col bg-background">
|
|
{/* 차트 영역 - 전체 공간 사용 */}
|
|
<div ref={containerRef} className="flex-1 overflow-hidden p-2">
|
|
{error ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-destructive text-sm">{error}</p>
|
|
</div>
|
|
) : !dataSources || dataSources.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">데이터 소스를 연결해주세요</p>
|
|
</div>
|
|
) : loading && data.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="text-center">
|
|
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
|
|
<p className="text-muted-foreground mt-2 text-xs">데이터 로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
) : !chartData ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-center text-sm">
|
|
차트 설정에서 데이터 소스를 추가하고
|
|
<br />
|
|
X축, Y축을 설정해주세요
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<Chart
|
|
chartType={chartType as any}
|
|
data={chartData}
|
|
config={chartConfig}
|
|
width={containerSize.width - 16}
|
|
height={containerSize.height - 16}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 푸터 - 주석 처리 (공간 확보) */}
|
|
{/* {data.length > 0 && (
|
|
<div className="text-muted-foreground border-t px-3 py-1.5 text-[10px]">
|
|
총 {data.length.toLocaleString()}개 데이터 표시 중
|
|
</div>
|
|
)} */}
|
|
</div>
|
|
);
|
|
}
|