2025-10-28 09:32:03 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
|
|
|
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
2025-10-29 11:52:18 +09:00
|
|
|
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
|
|
|
|
|
interface CustomMetricTestWidgetProps {
|
|
|
|
|
|
element: DashboardElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 필터 적용 함수
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
// 집계 함수 실행
|
|
|
|
|
|
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 CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
|
2025-10-31 18:27:43 +09:00
|
|
|
|
const [value, setValue] = useState<number>(0);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2025-11-14 16:55:52 +09:00
|
|
|
|
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
const config = element?.customMetricConfig;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadData();
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-11-14 16:55:52 +09:00
|
|
|
|
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
|
|
|
|
|
|
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
|
|
|
|
|
|
|
|
|
|
|
|
if (refreshInterval > 0) {
|
|
|
|
|
|
const interval = setInterval(loadData, refreshInterval * 1000);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2025-11-14 16:55:52 +09:00
|
|
|
|
}, [element, config?.refreshInterval]);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
const loadData = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(null);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
const dataSourceType = element?.dataSource?.type;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// Database 타입
|
|
|
|
|
|
if (dataSourceType === "database") {
|
|
|
|
|
|
if (!element?.dataSource?.query) {
|
|
|
|
|
|
setValue(0);
|
|
|
|
|
|
return;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
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,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
if (!response.ok) throw new Error("데이터 로딩 실패");
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
const result = await response.json();
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
if (result.success && result.data?.rows) {
|
|
|
|
|
|
let rows = result.data.rows;
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 필터 적용
|
|
|
|
|
|
if (config?.filters && config.filters.length > 0) {
|
|
|
|
|
|
rows = applyFilters(rows, config.filters);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
2025-10-28 13:40:17 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 집계 계산
|
|
|
|
|
|
if (config?.valueColumn && config?.aggregation) {
|
|
|
|
|
|
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
|
|
|
|
|
setValue(calculatedValue);
|
2025-11-14 16:55:52 +09:00
|
|
|
|
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
2025-10-31 18:27:43 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
setValue(0);
|
2025-10-29 18:26:06 +09:00
|
|
|
|
}
|
2025-10-31 18:27:43 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error(result.message || "데이터 로드 실패");
|
2025-10-28 13:40:17 +09:00
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
}
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// API 타입
|
|
|
|
|
|
else if (dataSourceType === "api") {
|
|
|
|
|
|
if (!element?.dataSource?.endpoint) {
|
|
|
|
|
|
setValue(0);
|
|
|
|
|
|
return;
|
2025-10-28 13:40:17 +09:00
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
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,
|
|
|
|
|
|
}),
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
if (!response.ok) throw new Error("API 호출 실패");
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
const result = await response.json();
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
if (result.success && result.data) {
|
|
|
|
|
|
let rows: any[] = [];
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 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];
|
|
|
|
|
|
}
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 필터 적용
|
|
|
|
|
|
if (config?.filters && config.filters.length > 0) {
|
|
|
|
|
|
rows = applyFilters(rows, config.filters);
|
|
|
|
|
|
}
|
2025-10-28 13:40:17 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 집계 계산
|
|
|
|
|
|
if (config?.valueColumn && config?.aggregation) {
|
|
|
|
|
|
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
|
|
|
|
|
setValue(calculatedValue);
|
2025-11-14 16:55:52 +09:00
|
|
|
|
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
2025-10-31 18:27:43 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
setValue(0);
|
|
|
|
|
|
}
|
2025-10-28 09:32:03 +09:00
|
|
|
|
} else {
|
2025-10-31 18:27:43 +09:00
|
|
|
|
throw new Error("API 응답 형식 오류");
|
2025-10-28 13:40:17 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-31 18:27:43 +09:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
if (loading) {
|
2025-10-28 09:32:03 +09:00
|
|
|
|
return (
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<div className="bg-background flex h-full items-center justify-center">
|
2025-10-28 17:40:48 +09:00
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
2025-10-28 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
}
|
2025-10-28 13:40:17 +09:00
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<div className="bg-background flex h-full items-center justify-center p-4">
|
2025-10-28 17:40:48 +09:00
|
|
|
|
<div className="text-center">
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<p className="text-destructive text-sm">⚠️ {error}</p>
|
2025-10-28 17:40:48 +09:00
|
|
|
|
<button
|
2025-10-31 18:27:43 +09:00
|
|
|
|
onClick={loadData}
|
2025-11-14 11:35:16 +09:00
|
|
|
|
className="bg-destructive/10 text-destructive hover:bg-destructive/20 mt-2 rounded px-3 py-1 text-xs"
|
2025-10-28 13:40:17 +09:00
|
|
|
|
>
|
2025-10-28 17:40:48 +09:00
|
|
|
|
다시 시도
|
|
|
|
|
|
</button>
|
2025-10-28 13:40:17 +09:00
|
|
|
|
</div>
|
2025-10-28 09:32:03 +09:00
|
|
|
|
</div>
|
2025-10-28 17:40:48 +09:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 설정 체크
|
|
|
|
|
|
const hasDataSource =
|
|
|
|
|
|
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
|
|
|
|
|
|
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
const hasConfig = config?.valueColumn && config?.aggregation;
|
|
|
|
|
|
|
|
|
|
|
|
// 설정이 없으면 안내 화면
|
|
|
|
|
|
if (!hasDataSource || !hasConfig) {
|
2025-10-28 17:40:48 +09:00
|
|
|
|
return (
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<div className="bg-background flex h-full items-center justify-center p-4">
|
2025-10-31 18:27:43 +09:00
|
|
|
|
<div className="max-w-xs space-y-2 text-center">
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<h3 className="text-foreground text-sm font-bold">통계 카드</h3>
|
|
|
|
|
|
<div className="text-foreground space-y-1.5 text-xs">
|
2025-10-31 18:27:43 +09:00
|
|
|
|
<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>
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<div className="bg-primary/10 text-primary mt-2 rounded-lg p-2 text-[10px]">
|
2025-10-31 18:27:43 +09:00
|
|
|
|
<p className="font-medium">⚙️ 설정 방법</p>
|
|
|
|
|
|
<p>1. 데이터 탭에서 쿼리 실행</p>
|
|
|
|
|
|
<p>2. 필터 조건 추가 (선택사항)</p>
|
|
|
|
|
|
<p>3. 계산 컬럼 및 방식 선택</p>
|
|
|
|
|
|
<p>4. 제목 및 단위 입력</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-28 09:32:03 +09:00
|
|
|
|
</div>
|
2025-10-28 17:40:48 +09:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-31 18:27:43 +09:00
|
|
|
|
// 소수점 자릿수 (기본: 0)
|
|
|
|
|
|
const decimals = config?.decimals ?? 0;
|
|
|
|
|
|
const formattedValue = value.toFixed(decimals);
|
|
|
|
|
|
|
2025-11-03 11:55:40 +09:00
|
|
|
|
// 통계 카드 렌더링 (전체 크기 꽉 차게)
|
2025-10-28 17:40:48 +09:00
|
|
|
|
return (
|
2025-11-14 11:35:16 +09:00
|
|
|
|
<div className="bg-card flex h-full w-full flex-col items-center justify-center rounded-lg p-6 text-center">
|
2025-11-03 11:55:40 +09:00
|
|
|
|
{/* 제목 */}
|
|
|
|
|
|
<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>}
|
2025-10-31 18:27:43 +09:00
|
|
|
|
</div>
|
2025-11-14 16:55:52 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 마지막 업데이트 시간 */}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-10-28 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|