"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([]); const [groupedCards, setGroupedCards] = useState>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (

데이터 로딩 중...

); } if (error) { return (

⚠️ {error}

); } // 데이터 소스 체크 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 (

사용자 커스텀 카드

📊 맞춤형 지표 위젯

  • • SQL 쿼리로 데이터를 불러옵니다
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • • 사용자 정의 단위 설정 가능
  • 그룹별 카드 생성 모드로 간편하게 사용 가능

⚙️ 설정 방법

{isGroupByMode ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" : "SQL 쿼리를 입력하고 지표를 추가하세요"}

{isGroupByMode &&

💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값

}
); } // 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로) // 실제 측정된 1칸 높이: 119px const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진) return (
{/* 그룹별 카드 (활성화 시) */} {isGroupByMode && groupedCards.map((card, index) => { // 색상 순환 (6가지 색상) const colorKeys = Object.keys(colorMap) as Array; const colorKey = colorKeys[index % colorKeys.length]; const colors = colorMap[colorKey]; return (
{card.label}
{card.value.toLocaleString()}
); })} {/* 일반 지표 카드 (항상 표시) */} {metrics.map((metric) => { const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; const formattedValue = metric.calculatedValue.toFixed(metric.decimals); return (
{metric.label}
{formattedValue} {metric.unit}
); })}
); }