"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Loader2, RefreshCw } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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([]); const [groupedCards, setGroupedCards] = useState>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); const [selectedMetric, setSelectedMetric] = useState(null); const [isDetailOpen, setIsDetailOpen] = useState(false); console.log("🧪 CustomMetricTestWidget 렌더링!", element); const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; }, [element?.dataSources, element?.chartConfig?.dataSources]); // 🆕 그룹별 카드 모드 체크 const isGroupByMode = element?.customMetricConfig?.groupByMode || false; // 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션 const metricConfig = useMemo(() => { return ( element?.customMetricConfig?.metrics || [ { label: "총 개수", field: "id", aggregation: "count", color: "indigo", }, ] ); }, [element?.customMetricConfig?.metrics]); // 🆕 그룹별 카드 데이터 로드 (원본에서 복사) const loadGroupByData = useCallback(async () => { const groupByDS = element?.customMetricConfig?.groupByDataSource; if (!groupByDS) return; const dataSourceType = groupByDS.type; // Database 타입 if (dataSourceType === "database") { if (!groupByDS.query) return; const { dashboardApi } = await import("@/lib/api/dashboard"); const result = await dashboardApi.executeQuery(groupByDS.query); 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 { dashboardApi } = await import("@/lib/api/dashboard"); const result = await dashboardApi.fetchExternalApi({ method: "GET", url: groupByDS.endpoint, headers: (groupByDS as any).headers || {}, }); 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); } } } }, [element?.customMetricConfig?.groupByDataSource]); // 다중 데이터 소스 로딩 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 { // 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리) const results = await Promise.allSettled( dataSources.map(async (source, sourceIndex) => { try { console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`); let rows: any[] = []; if (source.type === "api") { rows = await loadRestApiData(source); } else if (source.type === "database") { rows = await loadDatabaseData(source); } console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`); return { sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, sourceIndex: sourceIndex, rows: rows, }; } catch (err: any) { console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); return { sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, sourceIndex: sourceIndex, rows: [], }; } }), ); console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`); // 각 데이터 소스별로 메트릭 생성 const allMetrics: any[] = []; const colors = ["indigo", "green", "blue", "purple", "orange", "gray"]; results.forEach((result) => { if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) { return; } 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, rawData: rows, // 원본 데이터 저장 }); }); } 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, rawData: rows, // 원본 데이터 저장 }); }); // 총 행 개수도 추가 allMetrics.push({ label: `${sourceName} - 총 개수`, value: rows.length, field: "count", aggregation: "count", color: colors[allMetrics.length % colors.length], sourceName: sourceName, rawData: rows, // 원본 데이터 저장 }); } } 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, rawData: rows, // 원본 데이터 저장 }); }); // 총 행 개수 allMetrics.push({ label: `${sourceName} - 총 개수`, value: rows.length, field: "count", aggregation: "count", color: colors[allMetrics.length % colors.length], sourceName: sourceName, rawData: rows, // 원본 데이터 저장 }); } }); console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`); setMetrics(allMetrics); setLastRefreshTime(new Date()); } catch (err) { setError(err instanceof Error ? err.message : "데이터 로딩 실패"); } finally { setLoading(false); } }, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]); // 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭) const loadAllData = useCallback(async () => { try { setLoading(true); setError(null); // 그룹별 카드 데이터 로드 if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { await loadGroupByData(); } // 일반 메트릭 데이터 로드 if (dataSources && dataSources.length > 0) { await loadMultipleDataSources(); } } catch (err) { console.error("데이터 로드 실패:", err); setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); } finally { setLoading(false); } }, [ isGroupByMode, element?.customMetricConfig?.groupByDataSource, dataSources, loadGroupByData, loadMultipleDataSources, ]); // 수동 새로고침 핸들러 const handleManualRefresh = useCallback(() => { console.log("🔄 수동 새로고침 버튼 클릭"); loadAllData(); }, [loadAllData]); // 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("")) { 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; }; // REST API 데이터 로딩 const loadRestApiData = async (source: ChartDataSource): Promise => { if (!source.endpoint) { throw new Error("API endpoint가 없습니다."); } const params = new URLSearchParams(); // queryParams 배열 또는 객체 처리 if (source.queryParams) { 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)); } }); } } console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params)); 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) { 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)}`); } const result = await response.json(); console.log("✅ API 응답:", result); if (!result.success) { console.error("❌ API 실패:", result); throw new Error(result.message || result.error || "외부 API 호출 실패"); } let processedData = result.data; // 텍스트/XML 데이터 처리 if (typeof processedData === "string") { console.log("📄 텍스트 형식 데이터 감지"); processedData = parseTextData(processedData); } else if (processedData && typeof processedData === "object" && processedData.text) { console.log("📄 래핑된 텍스트 데이터 감지"); processedData = parseTextData(processedData.text); } // 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}"에서 데이터를 찾을 수 없습니다`); } } } 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; } } } const rows = Array.isArray(processedData) ? processedData : [processedData]; // 컬럼 매핑 적용 return applyColumnMapping(rows, source.columnMapping); }; // Database 데이터 로딩 const loadDatabaseData = async (source: ChartDataSource): Promise => { if (!source.query) { throw new Error("SQL 쿼리가 없습니다."); } let rows: any[] = []; 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[]; }; rows = resultData.rows; } else { // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); const result = await dashboardApi.executeQuery(source.query); rows = result.rows; } // 컬럼 매핑 적용 return applyColumnMapping(rows, source.columnMapping); }; // 초기 로드 (🆕 loadAllData 사용) useEffect(() => { if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) { loadAllData(); } }, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]); // 자동 새로고침 (🆕 loadAllData 사용) 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("🔄 자동 새로고침 실행"); loadAllData(); }, minInterval * 1000); return () => { console.log("⏹️ 자동 새로고침 정리"); clearInterval(intervalId); }; }, [dataSources, loadAllData]); // renderMetricCard 함수 제거 - 인라인으로 렌더링 // 로딩 상태 (원본 스타일) if (loading) { return (

데이터 로딩 중...

); } // 에러 상태 (원본 스타일) if (error) { return (

⚠️ {error}

); } // 데이터 소스 없음 (원본 스타일) if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) { return (

데이터 소스를 연결해주세요

); } // 메트릭 설정 없음 (원본 스타일) if (metricConfig.length === 0 && !isGroupByMode) { return (

메트릭을 설정해주세요

); } // 메인 렌더링 (원본 스타일 - 심플하게) 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, index) => { const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; const formattedValue = metric.value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2, }); return (
{ setSelectedMetric(metric); setIsDetailOpen(true); }} className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2 transition-all hover:shadow-md`} >
{metric.label}
{formattedValue} {metric.unit && {metric.unit}}
); })}
{/* 상세 정보 모달 */} {selectedMetric?.label || "메트릭 상세"} 데이터 소스: {selectedMetric?.sourceName} • 총 {selectedMetric?.rawData?.length || 0}개 항목
{/* 메트릭 요약 */}

계산 방법

{selectedMetric?.aggregation === "count" && "전체 데이터 개수"} {selectedMetric?.aggregation === "distinct" && `"${selectedMetric?.field}" 컬럼의 고유값 개수`} {selectedMetric?.aggregation === "custom" && `"${selectedMetric?.field}" 컬럼의 값`} {selectedMetric?.aggregation === "sum" && `"${selectedMetric?.field}" 컬럼의 합계`} {selectedMetric?.aggregation === "avg" && `"${selectedMetric?.field}" 컬럼의 평균`} {selectedMetric?.aggregation === "min" && `"${selectedMetric?.field}" 컬럼의 최소값`} {selectedMetric?.aggregation === "max" && `"${selectedMetric?.field}" 컬럼의 최대값`}

계산 결과

{selectedMetric?.value?.toLocaleString()} {selectedMetric?.unit && ` ${selectedMetric.unit}`} {selectedMetric?.aggregation === "distinct" && "개"} {selectedMetric?.aggregation === "count" && "개"}

전체 데이터 개수

{selectedMetric?.rawData?.length || 0}개

{/* 원본 데이터 테이블 */} {selectedMetric?.rawData && selectedMetric.rawData.length > 0 && (

원본 데이터 (최대 100개)

{Object.keys(selectedMetric.rawData[0]).map((col) => ( {col} ))} {selectedMetric.rawData.slice(0, 100).map((row: any, idx: number) => ( {Object.keys(selectedMetric.rawData[0]).map((col) => ( {String(row[col])} ))} ))}
{selectedMetric.rawData.length > 100 && (

총 {selectedMetric.rawData.length}개 중 100개만 표시됩니다

)}
)} {/* 데이터 없음 */} {(!selectedMetric?.rawData || selectedMetric.rawData.length === 0) && (

표시할 데이터가 없습니다

)}
); }