Files
vexplor/frontend/components/dashboard/widgets/CustomMetricWidget.tsx
2025-11-14 16:55:52 +09:00

299 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { getApiUrl } from "@/lib/utils/apiUrl";
interface CustomMetricWidgetProps {
element?: DashboardElement;
}
// 필터 적용 함수
const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => {
if (!filters || filters.length === 0) return rows;
return rows.filter((row) => {
return filters.every((filter) => {
const cellValue = String(row[filter.column] || "");
const filterValue = filter.value;
switch (filter.operator) {
case "=":
return cellValue === filterValue;
case "!=":
return cellValue !== filterValue;
case ">":
return parseFloat(cellValue) > parseFloat(filterValue);
case "<":
return parseFloat(cellValue) < parseFloat(filterValue);
case ">=":
return parseFloat(cellValue) >= parseFloat(filterValue);
case "<=":
return parseFloat(cellValue) <= parseFloat(filterValue);
case "contains":
return cellValue.includes(filterValue);
case "not_contains":
return !cellValue.includes(filterValue);
default:
return true;
}
});
});
};
// 집계 함수 실행
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;
}
};
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
const [value, setValue] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const config = element?.customMetricConfig;
useEffect(() => {
loadData();
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
if (refreshInterval > 0) {
const interval = setInterval(loadData, refreshInterval * 1000);
return () => clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element, config?.refreshInterval]);
const loadData = async () => {
try {
setLoading(true);
setError(null);
const dataSourceType = element?.dataSource?.type;
// Database 타입
if (dataSourceType === "database") {
if (!element?.dataSource?.query) {
setValue(0);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
connectionId: (element.dataSource as any).connectionId,
}),
});
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
let rows = result.data.rows;
// 필터 적용
if (config?.filters && config.filters.length > 0) {
rows = applyFilters(rows, config.filters);
}
// 집계 계산
if (config?.valueColumn && config?.aggregation) {
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
setValue(calculatedValue);
} else {
setValue(0);
}
} else {
throw new Error(result.message || "데이터 로드 실패");
}
}
// API 타입
else if (dataSourceType === "api") {
if (!element?.dataSource?.endpoint) {
setValue(0);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: (element.dataSource as any).method || "GET",
url: element.dataSource.endpoint,
headers: (element.dataSource as any).headers || {},
body: (element.dataSource as any).body,
authType: (element.dataSource as any).authType,
authConfig: (element.dataSource as any).authConfig,
}),
});
if (!response.ok) throw new Error("API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
let rows: any[] = [];
// API 응답 데이터 구조 확인 및 처리
if (Array.isArray(result.data)) {
rows = result.data;
} else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
} else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
} else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
} else {
rows = [result.data];
}
// 필터 적용
if (config?.filters && config.filters.length > 0) {
rows = applyFilters(rows, config.filters);
}
// 집계 계산
if (config?.valueColumn && config?.aggregation) {
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
setValue(calculatedValue);
} else {
setValue(0);
}
} else {
throw new Error("API 응답 형식 오류");
}
}
} catch (err) {
console.error("데이터 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
} finally {
setLoading(false);
setLastUpdateTime(new Date());
}
};
if (loading) {
return (
<div className="bg-background flex h-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-background flex h-full items-center justify-center p-4">
<div className="text-center">
<p className="text-destructive text-sm"> {error}</p>
<button
onClick={loadData}
className="bg-destructive/10 text-destructive hover:bg-destructive/20 mt-2 rounded px-3 py-1 text-xs"
>
</button>
</div>
</div>
);
}
// 설정 체크
const hasDataSource =
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
const hasConfig = config?.valueColumn && config?.aggregation;
// 설정이 없으면 안내 화면
if (!hasDataSource || !hasConfig) {
return (
<div className="bg-background flex h-full items-center justify-center p-4">
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-foreground text-sm font-bold"> </h3>
<div className="text-foreground space-y-1.5 text-xs">
<p className="font-medium">📊 </p>
<ul className="space-y-0.5 text-left">
<li> </li>
<li> </li>
<li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li>
</ul>
</div>
<div className="bg-primary/10 text-primary mt-2 rounded-lg p-2 text-[10px]">
<p className="font-medium"> </p>
<p>1. </p>
<p>2. ()</p>
<p>3. </p>
<p>4. </p>
</div>
</div>
</div>
);
}
// 소수점 자릿수 (기본: 0)
const decimals = config?.decimals ?? 0;
const formattedValue = value.toFixed(decimals);
// 통계 카드 렌더링
return (
<div className="bg-card flex h-full w-full flex-col items-center justify-center p-6 text-center">
{/* 제목 */}
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
{/* 값 */}
<div className="flex items-baseline gap-1">
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
</div>
{/* 마지막 업데이트 시간 */}
{lastUpdateTime && (
<div className="text-muted-foreground mt-3 text-[10px]">
{lastUpdateTime.toLocaleTimeString("ko-KR")}
{config?.refreshInterval && config.refreshInterval > 0 && (
<span className="ml-1"> {config.refreshInterval} </span>
)}
</div>
)}
</div>
);
}