625 lines
21 KiB
TypeScript
625 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Loader2, RefreshCw } from "lucide-react";
|
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
BarChart,
|
|
Bar,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
ComposedChart, // 🆕 바/라인/영역 혼합 차트
|
|
Area, // 🆕 영역 차트
|
|
} from "recharts";
|
|
|
|
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);
|
|
|
|
console.log("🧪 ChartTestWidget 렌더링!", element);
|
|
|
|
const dataSources = useMemo(() => {
|
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
|
|
|
// 다중 데이터 소스 로딩
|
|
const loadMultipleDataSources = useCallback(async () => {
|
|
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
|
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]);
|
|
|
|
// 수동 새로고침 핸들러
|
|
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("/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) {
|
|
// 외부 DB (ExternalDbConnectionAPI 사용)
|
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
|
result = await ExternalDbConnectionAPI.executeQuery(parseInt(source.externalConnectionId), source.query);
|
|
} else {
|
|
// 현재 DB (dashboardApi.executeQuery 사용)
|
|
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 || [];
|
|
|
|
console.log("💾 내부 DB 쿼리 결과:", {
|
|
hasRows: !!rows,
|
|
rowCount: rows.length,
|
|
hasColumns: rows.length > 0 && Object.keys(rows[0]).length > 0,
|
|
columnCount: rows.length > 0 ? Object.keys(rows[0]).length : 0,
|
|
firstRow: rows[0],
|
|
});
|
|
|
|
// 컬럼 매핑 적용
|
|
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
|
|
|
console.log("✅ 매핑 후:", {
|
|
columns: mappedRows.length > 0 ? Object.keys(mappedRows[0]) : [],
|
|
rowCount: mappedRows.length,
|
|
firstMappedRow: mappedRows[0],
|
|
});
|
|
|
|
return mappedRows;
|
|
};
|
|
|
|
// 초기 로드
|
|
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 || [];
|
|
|
|
// 멀티 데이터 소스 차트 렌더링
|
|
const renderChart = () => {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">데이터가 없습니다</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (dataSourceConfigs.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
차트 설정에서 데이터 소스를 추가하고
|
|
<br />
|
|
X축, Y축을 설정해주세요
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 병합 모드: 여러 데이터 소스를 하나의 라인/바로 합침
|
|
if (mergeMode && dataSourceConfigs.length > 1) {
|
|
const chartData: any[] = [];
|
|
const allXValues = new Set<string>();
|
|
|
|
// 첫 번째 데이터 소스의 설정을 기준으로 사용
|
|
const baseConfig = dataSourceConfigs[0];
|
|
const xAxisField = baseConfig.xAxis;
|
|
const yAxisField = baseConfig.yAxis[0];
|
|
|
|
// 모든 데이터 소스에서 데이터 수집 (X축 값 기준)
|
|
dataSourceConfigs.forEach((dsConfig) => {
|
|
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
|
const sourceData = data.filter((item) => item._source === sourceName);
|
|
|
|
sourceData.forEach((item) => {
|
|
const xValue = item[xAxisField];
|
|
if (xValue !== undefined) {
|
|
allXValues.add(String(xValue));
|
|
}
|
|
});
|
|
});
|
|
|
|
// X축 값별로 Y축 값 합산
|
|
allXValues.forEach((xValue) => {
|
|
const dataPoint: any = { _xValue: xValue };
|
|
let totalYValue = 0;
|
|
|
|
dataSourceConfigs.forEach((dsConfig) => {
|
|
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
|
const sourceData = data.filter((item) => item._source === sourceName);
|
|
const matchingItem = sourceData.find((item) => String(item[xAxisField]) === xValue);
|
|
|
|
if (matchingItem && yAxisField) {
|
|
const yValue = parseFloat(matchingItem[yAxisField]) || 0;
|
|
totalYValue += yValue;
|
|
}
|
|
});
|
|
|
|
dataPoint[yAxisField] = totalYValue;
|
|
chartData.push(dataPoint);
|
|
});
|
|
|
|
console.log("🔗 병합 모드 차트 데이터:", chartData);
|
|
|
|
// 병합 모드 차트 렌더링
|
|
switch (chartType) {
|
|
case "line":
|
|
return (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="_xValue" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Line type="monotone" dataKey={yAxisField} name={yAxisField} stroke={COLORS[0]} strokeWidth={2} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
|
|
case "bar":
|
|
return (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="_xValue" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey={yAxisField} name={yAxisField} fill={COLORS[0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
|
|
case "area":
|
|
return (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="_xValue" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Line
|
|
type="monotone"
|
|
dataKey={yAxisField}
|
|
name={yAxisField}
|
|
stroke={COLORS[0]}
|
|
fill={COLORS[0]}
|
|
fillOpacity={0.3}
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">병합 모드는 라인, 바, 영역 차트만 지원합니다</p>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// 일반 모드: 각 데이터 소스를 별도의 라인/바로 표시
|
|
const chartData: any[] = [];
|
|
const allXValues = new Set<string>();
|
|
|
|
// 1단계: 모든 X축 값 수집
|
|
dataSourceConfigs.forEach((dsConfig) => {
|
|
const sourceData = data.filter((item) => {
|
|
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
|
return item._source === sourceName;
|
|
});
|
|
|
|
sourceData.forEach((item) => {
|
|
const xValue = item[dsConfig.xAxis];
|
|
if (xValue !== undefined) {
|
|
allXValues.add(String(xValue));
|
|
}
|
|
});
|
|
});
|
|
|
|
// 2단계: X축 값별로 데이터 병합
|
|
allXValues.forEach((xValue) => {
|
|
const dataPoint: any = { _xValue: xValue };
|
|
|
|
dataSourceConfigs.forEach((dsConfig, index) => {
|
|
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
|
const sourceData = data.filter((item) => item._source === sourceName);
|
|
const matchingItem = sourceData.find((item) => String(item[dsConfig.xAxis]) === xValue);
|
|
|
|
if (matchingItem && dsConfig.yAxis.length > 0) {
|
|
const yField = dsConfig.yAxis[0];
|
|
dataPoint[`${sourceName}_${yField}`] = matchingItem[yField];
|
|
}
|
|
});
|
|
|
|
chartData.push(dataPoint);
|
|
});
|
|
|
|
console.log("📊 일반 모드 차트 데이터:", chartData);
|
|
console.log("📊 데이터 소스 설정:", dataSourceConfigs);
|
|
|
|
// 🆕 혼합 차트 타입 감지 (각 데이터 소스마다 다른 차트 타입이 설정된 경우)
|
|
const isMixedChart = dataSourceConfigs.some((dsConfig) => dsConfig.chartType);
|
|
const effectiveChartType = isMixedChart ? "mixed" : chartType;
|
|
|
|
// 차트 타입별 렌더링
|
|
switch (effectiveChartType) {
|
|
case "mixed":
|
|
case "line":
|
|
case "bar":
|
|
case "area":
|
|
// 🆕 ComposedChart 사용 (바/라인/영역 혼합 가능)
|
|
return (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="_xValue" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
{dataSourceConfigs.map((dsConfig, index) => {
|
|
const sourceName =
|
|
dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
|
const yField = dsConfig.yAxis[0];
|
|
const dataKey = `${sourceName}_${yField}`;
|
|
const label = dsConfig.label || sourceName;
|
|
const color = COLORS[index % COLORS.length];
|
|
|
|
// 개별 차트 타입 또는 전역 차트 타입 사용
|
|
const individualChartType = dsConfig.chartType || chartType;
|
|
|
|
// 차트 타입에 따라 다른 컴포넌트 렌더링
|
|
switch (individualChartType) {
|
|
case "bar":
|
|
return <Bar key={dsConfig.dataSourceId} dataKey={dataKey} name={label} fill={color} />;
|
|
case "area":
|
|
return (
|
|
<Area
|
|
key={dsConfig.dataSourceId}
|
|
type="monotone"
|
|
dataKey={dataKey}
|
|
name={label}
|
|
stroke={color}
|
|
fill={color}
|
|
fillOpacity={0.3}
|
|
strokeWidth={2}
|
|
/>
|
|
);
|
|
case "line":
|
|
default:
|
|
return (
|
|
<Line
|
|
key={dsConfig.dataSourceId}
|
|
type="monotone"
|
|
dataKey={dataKey}
|
|
name={label}
|
|
stroke={color}
|
|
strokeWidth={2}
|
|
/>
|
|
);
|
|
}
|
|
})}
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
|
|
case "pie":
|
|
case "donut":
|
|
// 파이 차트는 첫 번째 데이터 소스만 사용
|
|
if (dataSourceConfigs.length > 0) {
|
|
const firstConfig = dataSourceConfigs[0];
|
|
const sourceName = dataSources.find((ds) => ds.id === firstConfig.dataSourceId)?.name;
|
|
|
|
// 해당 데이터 소스의 데이터만 필터링
|
|
const sourceData = data.filter((item) => item._source === sourceName);
|
|
|
|
console.log("🍩 도넛/파이 차트 데이터:", {
|
|
sourceName,
|
|
totalData: data.length,
|
|
filteredData: sourceData.length,
|
|
firstConfig,
|
|
sampleItem: sourceData[0],
|
|
});
|
|
|
|
// 파이 차트용 데이터 변환
|
|
const pieData = sourceData.map((item) => ({
|
|
name: String(item[firstConfig.xAxis] || "Unknown"),
|
|
value: Number(item[firstConfig.yAxis[0]]) || 0,
|
|
}));
|
|
|
|
console.log("🍩 변환된 파이 데이터:", pieData);
|
|
console.log("🍩 첫 번째 데이터:", pieData[0]);
|
|
console.log("🍩 데이터 타입 체크:", {
|
|
firstValue: pieData[0]?.value,
|
|
valueType: typeof pieData[0]?.value,
|
|
isNumber: typeof pieData[0]?.value === "number",
|
|
});
|
|
|
|
if (pieData.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">파이 차트에 표시할 데이터가 없습니다.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// value가 모두 0인지 체크
|
|
const totalValue = pieData.reduce((sum, item) => sum + (item.value || 0), 0);
|
|
if (totalValue === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">모든 값이 0입니다. Y축 필드를 확인해주세요.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={pieData}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius="70%"
|
|
innerRadius={chartType === "donut" ? "45%" : 0}
|
|
label={(entry) => `${entry.name}: ${entry.value}`}
|
|
labelLine={true}
|
|
fill="#8884d8"
|
|
>
|
|
{pieData.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
<Legend verticalAlign="bottom" height={36} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">파이 차트를 표시하려면 데이터 소스를 설정하세요.</p>
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">지원하지 않는 차트 타입: {chartType}</p>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background flex h-full w-full flex-col">
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">{element?.customTitle || "차트"}</h3>
|
|
<p className="text-muted-foreground text-xs">
|
|
{dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터
|
|
{lastRefreshTime && <span className="ml-2">• {lastRefreshTime.toLocaleTimeString("ko-KR")}</span>}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleManualRefresh}
|
|
disabled={loading}
|
|
className="h-8 gap-2 text-xs"
|
|
>
|
|
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
|
새로고침
|
|
</Button>
|
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 p-4">
|
|
{error ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-destructive text-sm">{error}</p>
|
|
</div>
|
|
) : !(element?.dataSources || element?.chartConfig?.dataSources) ||
|
|
(element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">데이터 소스를 연결해주세요</p>
|
|
</div>
|
|
) : (
|
|
renderChart()
|
|
)}
|
|
</div>
|
|
|
|
{data.length > 0 && (
|
|
<div className="text-muted-foreground border-t p-2 text-xs">총 {data.length}개 데이터 표시 중</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|