426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
"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 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" },
|
||
};
|
||
|
||
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
|
||
const [metrics, setMetrics] = useState<any[]>([]);
|
||
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
|
||
// 자동 새로고침 (30초마다)
|
||
const interval = setInterval(loadData, 30000);
|
||
return () => clearInterval(interval);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [element]);
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
// 그룹별 카드 데이터 로드
|
||
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
|
||
await loadGroupByData();
|
||
}
|
||
|
||
// 일반 지표 데이터 로드
|
||
if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) {
|
||
await loadMetricsData();
|
||
}
|
||
} catch (err) {
|
||
console.error("데이터 로드 실패:", err);
|
||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 그룹별 카드 데이터 로드
|
||
const loadGroupByData = async () => {
|
||
const groupByDS = element?.customMetricConfig?.groupByDataSource;
|
||
if (!groupByDS) return;
|
||
|
||
const dataSourceType = groupByDS.type;
|
||
|
||
// Database 타입
|
||
if (dataSourceType === "database") {
|
||
if (!groupByDS.query) 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: groupByDS.query,
|
||
connectionType: groupByDS.connectionType || "current",
|
||
connectionId: (groupByDS as any).connectionId,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패");
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data?.rows) {
|
||
const rows = result.data.rows;
|
||
if (rows.length > 0) {
|
||
const columns = result.data.columns || Object.keys(rows[0]);
|
||
const labelColumn = columns[0];
|
||
const valueColumn = columns[1];
|
||
|
||
const cards = rows.map((row: any) => ({
|
||
label: String(row[labelColumn] || ""),
|
||
value: parseFloat(row[valueColumn]) || 0,
|
||
}));
|
||
|
||
setGroupedCards(cards);
|
||
}
|
||
}
|
||
}
|
||
// API 타입
|
||
else if (dataSourceType === "api") {
|
||
if (!groupByDS.endpoint) 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: (groupByDS as any).method || "GET",
|
||
url: groupByDS.endpoint,
|
||
headers: (groupByDS as any).headers || {},
|
||
body: (groupByDS as any).body,
|
||
authType: (groupByDS as any).authType,
|
||
authConfig: (groupByDS as any).authConfig,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("그룹별 카드 API 호출 실패");
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data) {
|
||
let rows: any[] = [];
|
||
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 (rows.length > 0) {
|
||
const columns = Object.keys(rows[0]);
|
||
const labelColumn = columns[0];
|
||
const valueColumn = columns[1];
|
||
|
||
const cards = rows.map((row: any) => ({
|
||
label: String(row[labelColumn] || ""),
|
||
value: parseFloat(row[valueColumn]) || 0,
|
||
}));
|
||
|
||
setGroupedCards(cards);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 일반 지표 데이터 로드
|
||
const loadMetricsData = async () => {
|
||
const dataSourceType = element?.dataSource?.type;
|
||
|
||
// Database 타입
|
||
if (dataSourceType === "database") {
|
||
if (!element?.dataSource?.query) {
|
||
setMetrics([]);
|
||
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) {
|
||
const rows = result.data.rows;
|
||
|
||
const calculatedMetrics =
|
||
element.customMetricConfig?.metrics.map((metric) => {
|
||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||
return {
|
||
...metric,
|
||
calculatedValue: value,
|
||
};
|
||
}) || [];
|
||
|
||
setMetrics(calculatedMetrics);
|
||
} else {
|
||
throw new Error(result.message || "데이터 로드 실패");
|
||
}
|
||
}
|
||
// API 타입
|
||
else if (dataSourceType === "api") {
|
||
if (!element?.dataSource?.endpoint) {
|
||
setMetrics([]);
|
||
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) {
|
||
// API 응답 데이터 구조 확인 및 처리
|
||
let rows: any[] = [];
|
||
|
||
// result.data가 배열인 경우
|
||
if (Array.isArray(result.data)) {
|
||
rows = result.data;
|
||
}
|
||
// result.data.results가 배열인 경우 (일반적인 API 응답 구조)
|
||
else if (result.data.results && Array.isArray(result.data.results)) {
|
||
rows = result.data.results;
|
||
}
|
||
// result.data.items가 배열인 경우
|
||
else if (result.data.items && Array.isArray(result.data.items)) {
|
||
rows = result.data.items;
|
||
}
|
||
// result.data.data가 배열인 경우
|
||
else if (result.data.data && Array.isArray(result.data.data)) {
|
||
rows = result.data.data;
|
||
}
|
||
// 그 외의 경우 단일 객체를 배열로 래핑
|
||
else {
|
||
rows = [result.data];
|
||
}
|
||
|
||
const calculatedMetrics =
|
||
element.customMetricConfig?.metrics.map((metric) => {
|
||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||
return {
|
||
...metric,
|
||
calculatedValue: value,
|
||
};
|
||
}) || [];
|
||
|
||
setMetrics(calculatedMetrics);
|
||
} else {
|
||
throw new Error("API 응답 형식 오류");
|
||
}
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-white">
|
||
<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="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-white p-4">
|
||
<div className="text-center">
|
||
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||
<button
|
||
onClick={loadData}
|
||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||
>
|
||
다시 시도
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 데이터 소스 체크
|
||
const hasMetricsDataSource =
|
||
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
|
||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
|
||
|
||
const hasGroupByDataSource =
|
||
isGroupByMode &&
|
||
element?.customMetricConfig?.groupByDataSource &&
|
||
((element.customMetricConfig.groupByDataSource.type === "database" &&
|
||
element.customMetricConfig.groupByDataSource.query) ||
|
||
(element.customMetricConfig.groupByDataSource.type === "api" &&
|
||
element.customMetricConfig.groupByDataSource.endpoint));
|
||
|
||
const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0;
|
||
|
||
// 둘 다 없으면 빈 화면 표시
|
||
const shouldShowEmpty =
|
||
(!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource);
|
||
|
||
if (shouldShowEmpty) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-white p-4">
|
||
<div className="max-w-xs space-y-2 text-center">
|
||
<h3 className="text-sm font-bold text-gray-900">사용자 커스텀 카드</h3>
|
||
<div className="space-y-1.5 text-xs text-gray-600">
|
||
<p className="font-medium">📊 맞춤형 지표 위젯</p>
|
||
<ul className="space-y-0.5 text-left">
|
||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
||
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
||
<li>• 사용자 정의 단위 설정 가능</li>
|
||
<li>
|
||
• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||
<p className="font-medium">⚙️ 설정 방법</p>
|
||
<p className="mb-1">
|
||
{isGroupByMode
|
||
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
||
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
||
</p>
|
||
{isGroupByMode && <p className="text-[9px]">💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값</p>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로)
|
||
// 실제 측정된 1칸 높이: 119px
|
||
const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진)
|
||
|
||
return (
|
||
<div
|
||
className={`flex h-full w-full overflow-hidden bg-white p-0.5 ${
|
||
isHorizontalLayout ? "flex-row gap-0.5" : "flex-col gap-0.5"
|
||
}`}
|
||
>
|
||
{/* 그룹별 카드 (활성화 시) */}
|
||
{isGroupByMode &&
|
||
groupedCards.map((card, index) => {
|
||
// 색상 순환 (6가지 색상)
|
||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||
const colorKey = colorKeys[index % colorKeys.length];
|
||
const colors = colorMap[colorKey];
|
||
|
||
return (
|
||
<div
|
||
key={`group-${index}`}
|
||
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
||
>
|
||
<div className="text-[8px] leading-tight text-gray-600">{card.label}</div>
|
||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 일반 지표 카드 (항상 표시) */}
|
||
{metrics.map((metric) => {
|
||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
||
|
||
return (
|
||
<div
|
||
key={metric.id}
|
||
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
||
>
|
||
<div className="text-[8px] leading-tight text-gray-600">{metric.label}</div>
|
||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>
|
||
{formattedValue}
|
||
<span className="ml-0 text-[8px]">{metric.unit}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|