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

619 lines
22 KiB
TypeScript
Raw Normal View History

2025-10-28 09:32:03 +09:00
"use client";
2025-10-28 13:40:17 +09:00
import React, { useState, useEffect, useCallback, useMemo } from "react";
2025-10-28 09:32:03 +09:00
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
2025-10-28 13:40:17 +09:00
import { Button } from "@/components/ui/button";
import { Loader2, RefreshCw } from "lucide-react";
2025-10-28 09:32:03 +09:00
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);
2025-10-28 13:40:17 +09:00
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
2025-10-28 09:32:03 +09:00
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
2025-10-28 13:40:17 +09:00
const dataSources = useMemo(() => {
return element?.dataSources || element?.chartConfig?.dataSources;
}, [element?.dataSources, element?.chartConfig?.dataSources]);
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
const metricConfig = useMemo(() => {
return element?.customMetricConfig?.metrics || [
{
label: "총 개수",
field: "id",
aggregation: "count",
color: "indigo",
},
];
}, [element?.customMetricConfig?.metrics]);
2025-10-28 09:32:03 +09:00
// 다중 데이터 소스 로딩
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 {
2025-10-28 13:40:17 +09:00
// 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리)
2025-10-28 09:32:03 +09:00
const results = await Promise.allSettled(
2025-10-28 13:40:17 +09:00
dataSources.map(async (source, sourceIndex) => {
2025-10-28 09:32:03 +09:00
try {
2025-10-28 13:40:17 +09:00
console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`);
2025-10-28 09:32:03 +09:00
2025-10-28 13:40:17 +09:00
let rows: any[] = [];
2025-10-28 09:32:03 +09:00
if (source.type === "api") {
2025-10-28 13:40:17 +09:00
rows = await loadRestApiData(source);
2025-10-28 09:32:03 +09:00
} else if (source.type === "database") {
2025-10-28 13:40:17 +09:00
rows = await loadDatabaseData(source);
2025-10-28 09:32:03 +09:00
}
2025-10-28 13:40:17 +09:00
console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`);
return {
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
sourceIndex: sourceIndex,
rows: rows,
};
2025-10-28 09:32:03 +09:00
} catch (err: any) {
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
2025-10-28 13:40:17 +09:00
return {
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
sourceIndex: sourceIndex,
rows: [],
};
2025-10-28 09:32:03 +09:00
}
})
);
2025-10-28 13:40:17 +09:00
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
// 각 데이터 소스별로 메트릭 생성
const allMetrics: any[] = [];
const colors = ["indigo", "green", "blue", "purple", "orange", "gray"];
2025-10-28 09:32:03 +09:00
results.forEach((result) => {
2025-10-28 13:40:17 +09:00
if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) {
return;
2025-10-28 09:32:03 +09:00
}
2025-10-28 13:40:17 +09:00
const { sourceName, rows } = result.value;
// 집계된 데이터인지 확인 (행이 적고 숫자 컬럼이 있으면)
const hasAggregatedData = rows.length > 0 && rows.length <= 100;
if (hasAggregatedData && rows.length > 0) {
const firstRow = rows[0];
const columns = Object.keys(firstRow);
// 숫자 컬럼 찾기
const numericColumns = columns.filter(col => {
const value = firstRow[col];
return typeof value === 'number' || !isNaN(Number(value));
});
// 문자열 컬럼 찾기
const stringColumns = columns.filter(col => {
const value = firstRow[col];
return typeof value === 'string' || !numericColumns.includes(col);
});
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
전체: columns,
숫자: numericColumns,
문자열: stringColumns
});
// 숫자 컬럼이 있으면 집계된 데이터로 판단
if (numericColumns.length > 0) {
console.log(`✅ [${sourceName}] 집계된 데이터, 각 행을 메트릭으로 변환`);
rows.forEach((row, index) => {
// 라벨: 첫 번째 문자열 컬럼
const labelField = stringColumns[0] || columns[0];
const label = String(row[labelField] || `항목 ${index + 1}`);
// 값: 첫 번째 숫자 컬럼
const valueField = numericColumns[0] || columns[1] || columns[0];
const value = Number(row[valueField]) || 0;
console.log(` [${sourceName}] 메트릭: ${label} = ${value}`);
allMetrics.push({
label: `${sourceName} - ${label}`,
value: value,
field: valueField,
aggregation: "custom",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
});
});
} else {
// 숫자 컬럼이 없으면 각 컬럼별 고유값 개수 표시
console.log(`📊 [${sourceName}] 문자열 데이터, 각 컬럼별 고유값 개수 표시`);
// 데이터 소스에서 선택된 컬럼 가져오기
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
);
const selectedColumns = dataSourceConfig?.selectedColumns || [];
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
columnsToShow.forEach((col) => {
// 해당 컬럼이 실제로 존재하는지 확인
if (!columns.includes(col)) {
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
return;
}
// 해당 컬럼의 고유값 개수 계산
const uniqueValues = new Set(rows.map(row => row[col]));
const uniqueCount = uniqueValues.size;
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
allMetrics.push({
label: `${sourceName} - ${col} (고유값)`,
value: uniqueCount,
field: col,
aggregation: "distinct",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
});
});
// 총 행 개수도 추가
allMetrics.push({
label: `${sourceName} - 총 개수`,
value: rows.length,
field: "count",
aggregation: "count",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
});
}
} else {
// 행이 많으면 각 컬럼별 고유값 개수 + 총 개수 표시
console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
const firstRow = rows[0];
const columns = Object.keys(firstRow);
// 데이터 소스에서 선택된 컬럼 가져오기
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
);
const selectedColumns = dataSourceConfig?.selectedColumns || [];
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
// 각 컬럼별 고유값 개수
columnsToShow.forEach((col) => {
// 해당 컬럼이 실제로 존재하는지 확인
if (!columns.includes(col)) {
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
return;
}
const uniqueValues = new Set(rows.map(row => row[col]));
const uniqueCount = uniqueValues.size;
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
allMetrics.push({
label: `${sourceName} - ${col} (고유값)`,
value: uniqueCount,
field: col,
aggregation: "distinct",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
});
});
// 총 행 개수
allMetrics.push({
label: `${sourceName} - 총 개수`,
value: rows.length,
field: "count",
aggregation: "count",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
});
}
});
2025-10-28 09:32:03 +09:00
2025-10-28 13:40:17 +09:00
console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`);
setMetrics(allMetrics);
setLastRefreshTime(new Date());
2025-10-28 09:32:03 +09:00
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
} finally {
setLoading(false);
}
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
2025-10-28 13:40:17 +09:00
// 수동 새로고침 핸들러
const handleManualRefresh = useCallback(() => {
console.log("🔄 수동 새로고침 버튼 클릭");
loadMultipleDataSources();
}, [loadMultipleDataSources]);
// XML 데이터 파싱
const parseXmlData = (xmlText: string): any[] => {
console.log("🔍 XML 파싱 시작");
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const records = xmlDoc.getElementsByTagName("record");
const result: any[] = [];
for (let i = 0; i < records.length; i++) {
const record = records[i];
const obj: any = {};
for (let j = 0; j < record.children.length; j++) {
const child = record.children[j];
obj[child.tagName] = child.textContent || "";
}
result.push(obj);
}
console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`);
return result;
} catch (error) {
console.error("❌ XML 파싱 실패:", error);
throw new Error("XML 파싱 실패");
}
};
// 텍스트/CSV 데이터 파싱
const parseTextData = (text: string): any[] => {
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
// XML 감지
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
console.log("📄 XML 형식 감지");
return parseXmlData(text);
}
// CSV 파싱
console.log("📄 CSV 형식으로 파싱 시도");
const lines = text.trim().split("\n");
if (lines.length === 0) return [];
const headers = lines[0].split(",").map(h => h.trim());
const result: any[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(",");
const obj: any = {};
headers.forEach((header, index) => {
obj[header] = values[index]?.trim() || "";
});
result.push(obj);
}
console.log(`✅ CSV 파싱 완료: ${result.length}개 행`);
return result;
};
2025-10-28 09:32:03 +09:00
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
if (!source.endpoint) {
throw new Error("API endpoint가 없습니다.");
}
const params = new URLSearchParams();
2025-10-28 13:40:17 +09:00
// queryParams 배열 또는 객체 처리
2025-10-28 09:32:03 +09:00
if (source.queryParams) {
2025-10-28 13:40:17 +09:00
if (Array.isArray(source.queryParams)) {
source.queryParams.forEach((param: any) => {
if (param.key && param.value) {
params.append(param.key, String(param.value));
}
});
} else {
Object.entries(source.queryParams).forEach(([key, value]) => {
if (key && value) {
params.append(key, String(value));
}
});
}
2025-10-28 09:32:03 +09:00
}
2025-10-28 13:40:17 +09:00
console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params));
2025-10-28 09:32:03 +09:00
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) {
2025-10-28 13:40:17 +09:00
const errorText = await response.text();
console.error("❌ API 호출 실패:", {
status: response.status,
statusText: response.statusText,
body: errorText.substring(0, 500),
});
throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 100)}`);
2025-10-28 09:32:03 +09:00
}
const result = await response.json();
2025-10-28 13:40:17 +09:00
console.log("✅ API 응답:", result);
2025-10-28 09:32:03 +09:00
if (!result.success) {
2025-10-28 13:40:17 +09:00
console.error("❌ API 실패:", result);
throw new Error(result.message || result.error || "외부 API 호출 실패");
2025-10-28 09:32:03 +09:00
}
let processedData = result.data;
2025-10-28 13:40:17 +09:00
// 텍스트/XML 데이터 처리
if (typeof processedData === "string") {
console.log("📄 텍스트 형식 데이터 감지");
processedData = parseTextData(processedData);
} else if (processedData && typeof processedData === "object" && processedData.text) {
console.log("📄 래핑된 텍스트 데이터 감지");
processedData = parseTextData(processedData.text);
}
2025-10-28 09:32:03 +09:00
// 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}"에서 데이터를 찾을 수 없습니다`);
}
}
2025-10-28 13:40:17 +09:00
} else if (!Array.isArray(processedData) && typeof processedData === "object") {
// JSON Path 없으면 자동으로 배열 찾기
console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도");
const arrayKeys = ["data", "items", "result", "records", "rows", "list"];
for (const key of arrayKeys) {
if (Array.isArray(processedData[key])) {
console.log(`✅ 배열 발견: ${key}`);
processedData = processedData[key];
break;
}
}
2025-10-28 09:32:03 +09:00
}
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(() => {
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
loadMultipleDataSources();
}
2025-10-28 13:40:17 +09:00
}, [dataSources, loadMultipleDataSources, metricConfig]);
// 자동 새로고침
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]);
2025-10-28 09:32:03 +09:00
// 메트릭 카드 렌더링
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>
);
};
2025-10-28 13:40:17 +09:00
// 메트릭 개수에 따라 그리드 컬럼 동적 결정
const getGridCols = () => {
const count = metrics.length;
if (count === 0) return "grid-cols-1";
if (count === 1) return "grid-cols-1";
if (count <= 4) return "grid-cols-1 sm:grid-cols-2";
return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
};
2025-10-28 09:32:03 +09:00
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">
2025-10-28 13:40:17 +09:00
{dataSources?.length || 0} {metrics.length}
{lastRefreshTime && (
<span className="ml-2">
{lastRefreshTime.toLocaleTimeString("ko-KR")}
</span>
)}
2025-10-28 09:32:03 +09:00
</p>
</div>
2025-10-28 13:40:17 +09:00
<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>
2025-10-28 09:32:03 +09:00
</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>
) : (
2025-10-28 13:40:17 +09:00
<div className={`grid gap-4 ${getGridCols()}`}>
2025-10-28 09:32:03 +09:00
{metrics.map((metric, index) => renderMetricCard(metric, index))}
</div>
)}
</div>
</div>
);
}