ERP-node/frontend/components/dashboard/widgets/ChartTestWidget.tsx

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>
);
}