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

284 lines
9.6 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
import { Loader2 } from "lucide-react";
interface CustomMetricTestWidgetProps {
element: DashboardElement;
}
// 집계 함수 실행
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
if (rows.length === 0) return 0;
switch (aggregation) {
case "count":
return rows.length;
case "sum": {
return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0);
}
case "avg": {
const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0);
return rows.length > 0 ? sum / rows.length : 0;
}
case "min": {
return Math.min(...rows.map((row) => parseFloat(row[field]) || 0));
}
case "max": {
return Math.max(...rows.map((row) => parseFloat(row[field]) || 0));
}
default:
return 0;
}
};
// 색상 스타일 매핑
const colorMap = {
indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" },
green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" },
blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" },
purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" },
orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" },
gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" },
};
/**
* 커스텀 메트릭 테스트 위젯 (다중 데이터 소스 지원)
* - 여러 REST API 연결 가능
* - 여러 Database 연결 가능
* - REST API + Database 혼합 가능
* - 데이터 자동 병합 후 집계
*/
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
const [metrics, setMetrics] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
const metricConfig = element?.customMetricConfig?.metrics || [];
// 다중 데이터 소스 로딩
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 allRows: any[] = [];
results.forEach((result) => {
if (result.status === "fulfilled" && Array.isArray(result.value)) {
allRows.push(...result.value);
}
});
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
// 메트릭 계산
const calculatedMetrics = metricConfig.map((metric) => ({
...metric,
value: calculateMetric(allRows, metric.field, metric.aggregation),
}));
setMetrics(calculatedMetrics);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
} finally {
setLoading(false);
}
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
if (!source.endpoint) {
throw new Error("API endpoint가 없습니다.");
}
const params = new URLSearchParams();
if (source.queryParams) {
Object.entries(source.queryParams).forEach(([key, value]) => {
if (key && value) {
params.append(key, String(value));
}
});
}
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: source.endpoint,
method: "GET",
headers: source.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 호출 실패");
}
let processedData = result.data;
// JSON Path 처리
if (source.jsonPath) {
const paths = source.jsonPath.split(".");
for (const path of paths) {
if (processedData && typeof processedData === "object" && path in processedData) {
processedData = processedData[path];
} else {
throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`);
}
}
}
return Array.isArray(processedData) ? processedData : [processedData];
};
// Database 데이터 로딩
const loadDatabaseData = async (source: ChartDataSource): Promise<any[]> => {
if (!source.query) {
throw new Error("SQL 쿼리가 없습니다.");
}
if (source.connectionType === "external" && source.externalConnectionId) {
// 외부 DB
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
const externalResult = await ExternalDbConnectionAPI.executeQuery(
parseInt(source.externalConnectionId),
source.query,
);
if (!externalResult.success || !externalResult.data) {
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
}
const resultData = externalResult.data as unknown as {
rows: Record<string, unknown>[];
};
return resultData.rows;
} else {
// 현재 DB
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(source.query);
return result.rows;
}
};
// 초기 로드
useEffect(() => {
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
loadMultipleDataSources();
}
}, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources, metricConfig]);
// 메트릭 카드 렌더링
const renderMetricCard = (metric: any, index: number) => {
const color = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.value.toLocaleString(undefined, {
minimumFractionDigits: metric.decimals || 0,
maximumFractionDigits: metric.decimals || 0,
});
return (
<div
key={index}
className={`rounded-lg border ${color.border} ${color.bg} p-4 shadow-sm transition-all hover:shadow-md`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-xs font-medium text-muted-foreground">{metric.label}</p>
<p className={`mt-1 text-2xl font-bold ${color.text}`}>
{formattedValue}
{metric.unit && <span className="ml-1 text-sm">{metric.unit}</span>}
</p>
</div>
</div>
</div>
);
};
return (
<div className="flex h-full flex-col rounded-lg border bg-card shadow-sm">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-lg font-semibold">
{element?.customTitle || "커스텀 메트릭 (다중 데이터 소스)"}
</h3>
<p className="text-xs text-muted-foreground">
{(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}
</p>
</div>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
{/* 컨텐츠 */}
<div className="flex-1 overflow-auto p-4">
{error ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-destructive">{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-sm text-muted-foreground">
</p>
</div>
) : metricConfig.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{metrics.map((metric, index) => renderMetricCard(metric, index))}
</div>
)}
</div>
</div>
);
}