2025-10-28 09:32:03 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
2025-10-28 09:32:03 +09:00
|
|
|
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
2025-10-28 13:40:17 +09:00
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Loader2, RefreshCw } from "lucide-react";
|
2025-10-28 17:40:48 +09:00
|
|
|
|
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";
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
|
|
|
|
|
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" },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-28 17:40:48 +09:00
|
|
|
|
* 통계 카드 위젯 (다중 데이터 소스 지원)
|
2025-10-28 09:32:03 +09:00
|
|
|
|
* - 여러 REST API 연결 가능
|
|
|
|
|
|
* - 여러 Database 연결 가능
|
|
|
|
|
|
* - REST API + Database 혼합 가능
|
|
|
|
|
|
* - 데이터 자동 병합 후 집계
|
|
|
|
|
|
*/
|
|
|
|
|
|
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
|
|
|
|
|
|
const [metrics, setMetrics] = useState<any[]>([]);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
const [selectedMetric, setSelectedMetric] = useState<any | null>(null);
|
|
|
|
|
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
|
|
|
|
|
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const dataSources = useMemo(() => {
|
|
|
|
|
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
|
|
|
|
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
|
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
// 🆕 그룹별 카드 모드 체크
|
|
|
|
|
|
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
|
|
|
|
|
|
const metricConfig = useMemo(() => {
|
2025-10-28 17:40:48 +09:00
|
|
|
|
return (
|
|
|
|
|
|
element?.customMetricConfig?.metrics || [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: "총 개수",
|
|
|
|
|
|
field: "id",
|
|
|
|
|
|
aggregation: "count",
|
|
|
|
|
|
color: "indigo",
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
2025-10-28 13:40:17 +09:00
|
|
|
|
}, [element?.customMetricConfig?.metrics]);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
// 🆕 그룹별 카드 데이터 로드 (원본에서 복사)
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
// 다중 데이터 소스 로딩
|
|
|
|
|
|
const loadMultipleDataSources = useCallback(async () => {
|
|
|
|
|
|
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
if (!dataSources || dataSources.length === 0) {
|
|
|
|
|
|
console.log("⚠️ 데이터 소스가 없습니다.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리)
|
2025-10-28 09:32:03 +09:00
|
|
|
|
const results = await Promise.allSettled(
|
2025-10-28 13:40:17 +09:00
|
|
|
|
dataSources.map(async (source, sourceIndex) => {
|
2025-10-28 09:32:03 +09:00
|
|
|
|
try {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
let rows: any[] = [];
|
2025-10-28 09:32:03 +09:00
|
|
|
|
if (source.type === "api") {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
rows = await loadRestApiData(source);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
} else if (source.type === "database") {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
rows = await loadDatabaseData(source);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
return {
|
|
|
|
|
|
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
|
|
|
|
|
|
sourceIndex: sourceIndex,
|
|
|
|
|
|
rows: rows,
|
|
|
|
|
|
};
|
2025-10-28 09:32:03 +09:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
2025-10-28 13:40:17 +09:00
|
|
|
|
return {
|
|
|
|
|
|
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
|
|
|
|
|
|
sourceIndex: sourceIndex,
|
|
|
|
|
|
rows: [],
|
|
|
|
|
|
};
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
}),
|
2025-10-28 09:32:03 +09:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
|
|
|
|
|
|
|
|
|
|
|
// 각 데이터 소스별로 메트릭 생성
|
|
|
|
|
|
const allMetrics: any[] = [];
|
|
|
|
|
|
const colors = ["indigo", "green", "blue", "purple", "orange", "gray"];
|
|
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
results.forEach((result) => {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) {
|
|
|
|
|
|
return;
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const { sourceName, rows } = result.value;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 집계된 데이터인지 확인 (행이 적고 숫자 컬럼이 있으면)
|
|
|
|
|
|
const hasAggregatedData = rows.length > 0 && rows.length <= 100;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
if (hasAggregatedData && rows.length > 0) {
|
|
|
|
|
|
const firstRow = rows[0];
|
|
|
|
|
|
const columns = Object.keys(firstRow);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 숫자 컬럼 찾기
|
2025-10-28 17:40:48 +09:00
|
|
|
|
const numericColumns = columns.filter((col) => {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const value = firstRow[col];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
return typeof value === "number" || !isNaN(Number(value));
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 문자열 컬럼 찾기
|
2025-10-28 17:40:48 +09:00
|
|
|
|
const stringColumns = columns.filter((col) => {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const value = firstRow[col];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
return typeof value === "string" || !numericColumns.includes(col);
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
|
|
|
|
|
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
|
|
|
|
|
|
전체: columns,
|
|
|
|
|
|
숫자: numericColumns,
|
|
|
|
|
|
문자열: stringColumns,
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 숫자 컬럼이 있으면 집계된 데이터로 판단
|
|
|
|
|
|
if (numericColumns.length > 0) {
|
|
|
|
|
|
console.log(`✅ [${sourceName}] 집계된 데이터, 각 행을 메트릭으로 변환`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
rows.forEach((row, index) => {
|
|
|
|
|
|
// 라벨: 첫 번째 문자열 컬럼
|
|
|
|
|
|
const labelField = stringColumns[0] || columns[0];
|
|
|
|
|
|
const label = String(row[labelField] || `항목 ${index + 1}`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 값: 첫 번째 숫자 컬럼
|
|
|
|
|
|
const valueField = numericColumns[0] || columns[1] || columns[0];
|
|
|
|
|
|
const value = Number(row[valueField]) || 0;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(` [${sourceName}] 메트릭: ${label} = ${value}`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
allMetrics.push({
|
|
|
|
|
|
label: `${sourceName} - ${label}`,
|
|
|
|
|
|
value: value,
|
|
|
|
|
|
field: valueField,
|
|
|
|
|
|
aggregation: "custom",
|
|
|
|
|
|
color: colors[allMetrics.length % colors.length],
|
|
|
|
|
|
sourceName: sourceName,
|
2025-10-28 17:40:48 +09:00
|
|
|
|
rawData: rows, // 원본 데이터 저장
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 숫자 컬럼이 없으면 각 컬럼별 고유값 개수 표시
|
|
|
|
|
|
console.log(`📊 [${sourceName}] 문자열 데이터, 각 컬럼별 고유값 개수 표시`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 데이터 소스에서 선택된 컬럼 가져오기
|
|
|
|
|
|
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
2025-10-28 17:40:48 +09:00
|
|
|
|
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
2025-10-28 13:40:17 +09:00
|
|
|
|
);
|
|
|
|
|
|
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
|
|
|
|
|
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
columnsToShow.forEach((col) => {
|
|
|
|
|
|
// 해당 컬럼이 실제로 존재하는지 확인
|
|
|
|
|
|
if (!columns.includes(col)) {
|
|
|
|
|
|
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 해당 컬럼의 고유값 개수 계산
|
2025-10-28 17:40:48 +09:00
|
|
|
|
const uniqueValues = new Set(rows.map((row) => row[col]));
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const uniqueCount = uniqueValues.size;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
allMetrics.push({
|
|
|
|
|
|
label: `${sourceName} - ${col} (고유값)`,
|
|
|
|
|
|
value: uniqueCount,
|
|
|
|
|
|
field: col,
|
|
|
|
|
|
aggregation: "distinct",
|
|
|
|
|
|
color: colors[allMetrics.length % colors.length],
|
|
|
|
|
|
sourceName: sourceName,
|
2025-10-28 17:40:48 +09:00
|
|
|
|
rawData: rows, // 원본 데이터 저장
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 총 행 개수도 추가
|
|
|
|
|
|
allMetrics.push({
|
|
|
|
|
|
label: `${sourceName} - 총 개수`,
|
|
|
|
|
|
value: rows.length,
|
|
|
|
|
|
field: "count",
|
|
|
|
|
|
aggregation: "count",
|
|
|
|
|
|
color: colors[allMetrics.length % colors.length],
|
|
|
|
|
|
sourceName: sourceName,
|
2025-10-28 17:40:48 +09:00
|
|
|
|
rawData: rows, // 원본 데이터 저장
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 행이 많으면 각 컬럼별 고유값 개수 + 총 개수 표시
|
|
|
|
|
|
console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const firstRow = rows[0];
|
|
|
|
|
|
const columns = Object.keys(firstRow);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 데이터 소스에서 선택된 컬럼 가져오기
|
|
|
|
|
|
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
2025-10-28 17:40:48 +09:00
|
|
|
|
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
2025-10-28 13:40:17 +09:00
|
|
|
|
);
|
|
|
|
|
|
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
|
|
|
|
|
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 각 컬럼별 고유값 개수
|
|
|
|
|
|
columnsToShow.forEach((col) => {
|
|
|
|
|
|
// 해당 컬럼이 실제로 존재하는지 확인
|
|
|
|
|
|
if (!columns.includes(col)) {
|
|
|
|
|
|
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
|
|
|
|
|
const uniqueValues = new Set(rows.map((row) => row[col]));
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const uniqueCount = uniqueValues.size;
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
allMetrics.push({
|
|
|
|
|
|
label: `${sourceName} - ${col} (고유값)`,
|
|
|
|
|
|
value: uniqueCount,
|
|
|
|
|
|
field: col,
|
|
|
|
|
|
aggregation: "distinct",
|
|
|
|
|
|
color: colors[allMetrics.length % colors.length],
|
|
|
|
|
|
sourceName: sourceName,
|
2025-10-28 17:40:48 +09:00
|
|
|
|
rawData: rows, // 원본 데이터 저장
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 총 행 개수
|
|
|
|
|
|
allMetrics.push({
|
|
|
|
|
|
label: `${sourceName} - 총 개수`,
|
|
|
|
|
|
value: rows.length,
|
|
|
|
|
|
field: "count",
|
|
|
|
|
|
aggregation: "count",
|
|
|
|
|
|
color: colors[allMetrics.length % colors.length],
|
|
|
|
|
|
sourceName: sourceName,
|
2025-10-28 17:40:48 +09:00
|
|
|
|
rawData: rows, // 원본 데이터 저장
|
2025-10-28 13:40:17 +09:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`);
|
|
|
|
|
|
setMetrics(allMetrics);
|
|
|
|
|
|
setLastRefreshTime(new Date());
|
2025-10-28 09:32:03 +09:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
|
|
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
// 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭)
|
|
|
|
|
|
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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 수동 새로고침 핸들러
|
|
|
|
|
|
const handleManualRefresh = useCallback(() => {
|
|
|
|
|
|
console.log("🔄 수동 새로고침 버튼 클릭");
|
2025-10-28 17:40:48 +09:00
|
|
|
|
loadAllData();
|
|
|
|
|
|
}, [loadAllData]);
|
2025-10-28 13:40:17 +09:00
|
|
|
|
|
|
|
|
|
|
// XML 데이터 파싱
|
|
|
|
|
|
const parseXmlData = (xmlText: string): any[] => {
|
|
|
|
|
|
console.log("🔍 XML 파싱 시작");
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parser = new DOMParser();
|
|
|
|
|
|
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const records = xmlDoc.getElementsByTagName("record");
|
|
|
|
|
|
const result: any[] = [];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
for (let i = 0; i < records.length; i++) {
|
|
|
|
|
|
const record = records[i];
|
|
|
|
|
|
const obj: any = {};
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
for (let j = 0; j < record.children.length; j++) {
|
|
|
|
|
|
const child = record.children[j];
|
|
|
|
|
|
obj[child.tagName] = child.textContent || "";
|
|
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
result.push(obj);
|
|
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
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));
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// XML 감지
|
|
|
|
|
|
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
|
|
|
|
|
console.log("📄 XML 형식 감지");
|
|
|
|
|
|
return parseXmlData(text);
|
|
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// CSV 파싱
|
|
|
|
|
|
console.log("📄 CSV 형식으로 파싱 시도");
|
|
|
|
|
|
const lines = text.trim().split("\n");
|
|
|
|
|
|
if (lines.length === 0) return [];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
|
|
|
|
|
const headers = lines[0].split(",").map((h) => h.trim());
|
2025-10-28 13:40:17 +09:00
|
|
|
|
const result: any[] = [];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
|
|
|
|
const values = lines[i].split(",");
|
|
|
|
|
|
const obj: any = {};
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
headers.forEach((header, index) => {
|
|
|
|
|
|
obj[header] = values[index]?.trim() || "";
|
|
|
|
|
|
});
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
result.push(obj);
|
|
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log(`✅ CSV 파싱 완료: ${result.length}개 행`);
|
|
|
|
|
|
return result;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
// REST API 데이터 로딩
|
|
|
|
|
|
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
|
|
|
|
|
if (!source.endpoint) {
|
|
|
|
|
|
throw new Error("API endpoint가 없습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams();
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// queryParams 배열 또는 객체 처리
|
2025-10-28 09:32:03 +09:00
|
|
|
|
if (source.queryParams) {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params));
|
|
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
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) {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
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)}`);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.log("✅ API 응답:", result);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
|
|
|
|
|
if (!result.success) {
|
2025-10-28 13:40:17 +09:00
|
|
|
|
console.error("❌ API 실패:", result);
|
|
|
|
|
|
throw new Error(result.message || result.error || "외부 API 호출 실패");
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let processedData = result.data;
|
|
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
// 텍스트/XML 데이터 처리
|
|
|
|
|
|
if (typeof processedData === "string") {
|
|
|
|
|
|
console.log("📄 텍스트 형식 데이터 감지");
|
|
|
|
|
|
processedData = parseTextData(processedData);
|
|
|
|
|
|
} else if (processedData && typeof processedData === "object" && processedData.text) {
|
|
|
|
|
|
console.log("📄 래핑된 텍스트 데이터 감지");
|
|
|
|
|
|
processedData = parseTextData(processedData.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
// 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}"에서 데이터를 찾을 수 없습니다`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-28 13:40:17 +09:00
|
|
|
|
} else if (!Array.isArray(processedData) && typeof processedData === "object") {
|
|
|
|
|
|
// JSON Path 없으면 자동으로 배열 찾기
|
|
|
|
|
|
console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도");
|
|
|
|
|
|
const arrayKeys = ["data", "items", "result", "records", "rows", "list"];
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 13:40:17 +09:00
|
|
|
|
for (const key of arrayKeys) {
|
|
|
|
|
|
if (Array.isArray(processedData[key])) {
|
|
|
|
|
|
console.log(`✅ 배열 발견: ${key}`);
|
|
|
|
|
|
processedData = processedData[key];
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
|
|
|
|
|
|
|
|
|
|
|
// 컬럼 매핑 적용
|
|
|
|
|
|
return applyColumnMapping(rows, source.columnMapping);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Database 데이터 로딩
|
|
|
|
|
|
const loadDatabaseData = async (source: ChartDataSource): Promise<any[]> => {
|
|
|
|
|
|
if (!source.query) {
|
|
|
|
|
|
throw new Error("SQL 쿼리가 없습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
let rows: any[] = [];
|
|
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
if (source.connectionType === "external" && source.externalConnectionId) {
|
|
|
|
|
|
// 외부 DB
|
|
|
|
|
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
|
|
|
|
|
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
|
|
|
|
|
parseInt(source.externalConnectionId),
|
|
|
|
|
|
source.query,
|
|
|
|
|
|
);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
|
if (!externalResult.success || !externalResult.data) {
|
|
|
|
|
|
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resultData = externalResult.data as unknown as {
|
|
|
|
|
|
rows: Record<string, unknown>[];
|
|
|
|
|
|
};
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
|
|
|
|
|
rows = resultData.rows;
|
2025-10-28 09:32:03 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
// 현재 DB
|
|
|
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
|
|
|
|
const result = await dashboardApi.executeQuery(source.query);
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
|
|
|
|
|
rows = result.rows;
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
|
|
|
|
|
|
// 컬럼 매핑 적용
|
|
|
|
|
|
return applyColumnMapping(rows, source.columnMapping);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
// 초기 로드 (🆕 loadAllData 사용)
|
2025-10-28 09:32:03 +09:00
|
|
|
|
useEffect(() => {
|
2025-10-28 17:40:48 +09:00
|
|
|
|
if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) {
|
|
|
|
|
|
loadAllData();
|
2025-10-28 09:32:03 +09:00
|
|
|
|
}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
}, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]);
|
2025-10-28 13:40:17 +09:00
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
// 자동 새로고침 (🆕 loadAllData 사용)
|
2025-10-28 13:40:17 +09:00
|
|
|
|
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("🔄 자동 새로고침 실행");
|
2025-10-28 17:40:48 +09:00
|
|
|
|
loadAllData();
|
2025-10-28 13:40:17 +09:00
|
|
|
|
}, minInterval * 1000);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
console.log("⏹️ 자동 새로고침 정리");
|
|
|
|
|
|
clearInterval(intervalId);
|
|
|
|
|
|
};
|
2025-10-28 17:40:48 +09:00
|
|
|
|
}, [dataSources, loadAllData]);
|
2025-10-28 09:32:03 +09:00
|
|
|
|
|
2025-10-28 17:40:48 +09:00
|
|
|
|
// renderMetricCard 함수 제거 - 인라인으로 렌더링
|
|
|
|
|
|
|
|
|
|
|
|
// 로딩 상태 (원본 스타일)
|
|
|
|
|
|
if (loading) {
|
2025-10-28 09:32:03 +09:00
|
|
|
|
return (
|
2025-10-28 17:40:48 +09:00
|
|
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
|
|
|
|
|
<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>
|
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 (
|
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<p className="text-sm text-red-600">⚠️ {error}</p>
|
|
|
|
|
|
<button
|
2025-10-28 13:40:17 +09:00
|
|
|
|
onClick={handleManualRefresh}
|
2025-10-28 17:40:48 +09:00
|
|
|
|
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
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-28 17:40:48 +09:00
|
|
|
|
// 데이터 소스 없음 (원본 스타일)
|
|
|
|
|
|
if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
|
|
|
|
|
<p className="text-sm text-gray-500">데이터 소스를 연결해주세요</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 메트릭 설정 없음 (원본 스타일)
|
|
|
|
|
|
if (metricConfig.length === 0 && !isGroupByMode) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
|
|
|
|
|
<p className="text-sm text-gray-500">메트릭을 설정해주세요</p>
|
2025-10-28 09:32:03 +09:00
|
|
|
|
</div>
|
2025-10-28 17:40:48 +09:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 메인 렌더링 (원본 스타일 - 심플하게)
|
|
|
|
|
|
return (
|
2025-10-28 18:21:00 +09:00
|
|
|
|
<div className="flex h-full w-full flex-col bg-white p-2">
|
|
|
|
|
|
{/* 콘텐츠 영역 - 스크롤 가능하도록 개선 */}
|
|
|
|
|
|
<div className="grid w-full gap-2 overflow-y-auto" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
|
2025-10-28 17:40:48 +09:00
|
|
|
|
{/* 그룹별 카드 (활성화 시) */}
|
|
|
|
|
|
{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-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-[10px] text-gray-600">{card.label}</div>
|
|
|
|
|
|
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 일반 지표 카드 (항상 표시) */}
|
|
|
|
|
|
{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 (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`metric-${index}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
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`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-[10px] text-gray-600">{metric.label}</div>
|
|
|
|
|
|
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
|
|
|
|
|
|
{formattedValue}
|
|
|
|
|
|
{metric.unit && <span className="ml-0.5 text-sm">{metric.unit}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 상세 정보 모달 */}
|
|
|
|
|
|
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
|
|
|
|
|
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-[800px]">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle className="text-base sm:text-lg">{selectedMetric?.label || "메트릭 상세"}</DialogTitle>
|
|
|
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
|
데이터 소스: {selectedMetric?.sourceName} • 총 {selectedMetric?.rawData?.length || 0}개 항목
|
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* 메트릭 요약 */}
|
|
|
|
|
|
<div className="bg-muted/50 rounded-lg border p-4">
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-muted-foreground text-xs">계산 방법</p>
|
|
|
|
|
|
<p className="text-sm font-semibold">
|
|
|
|
|
|
{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}" 컬럼의 최대값`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-muted-foreground text-xs">계산 결과</p>
|
|
|
|
|
|
<p className="text-primary text-lg font-bold">
|
|
|
|
|
|
{selectedMetric?.value?.toLocaleString()}
|
|
|
|
|
|
{selectedMetric?.unit && ` ${selectedMetric.unit}`}
|
|
|
|
|
|
{selectedMetric?.aggregation === "distinct" && "개"}
|
|
|
|
|
|
{selectedMetric?.aggregation === "count" && "개"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-muted-foreground text-xs">전체 데이터 개수</p>
|
|
|
|
|
|
<p className="text-lg font-bold">{selectedMetric?.rawData?.length || 0}개</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 원본 데이터 테이블 */}
|
|
|
|
|
|
{selectedMetric?.rawData && selectedMetric.rawData.length > 0 && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h4 className="mb-2 text-sm font-semibold">원본 데이터 (최대 100개)</h4>
|
|
|
|
|
|
<div className="rounded-lg border">
|
|
|
|
|
|
<div className="max-h-96 overflow-auto">
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow className="bg-muted/50">
|
|
|
|
|
|
{Object.keys(selectedMetric.rawData[0]).map((col) => (
|
|
|
|
|
|
<TableHead key={col} className="text-xs font-semibold">
|
|
|
|
|
|
{col}
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{selectedMetric.rawData.slice(0, 100).map((row: any, idx: number) => (
|
|
|
|
|
|
<TableRow key={idx}>
|
|
|
|
|
|
{Object.keys(selectedMetric.rawData[0]).map((col) => (
|
|
|
|
|
|
<TableCell key={col} className="text-xs">
|
|
|
|
|
|
{String(row[col])}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{selectedMetric.rawData.length > 100 && (
|
|
|
|
|
|
<p className="text-muted-foreground mt-2 text-center text-xs">
|
|
|
|
|
|
총 {selectedMetric.rawData.length}개 중 100개만 표시됩니다
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 데이터 없음 */}
|
|
|
|
|
|
{(!selectedMetric?.rawData || selectedMetric.rawData.length === 0) && (
|
|
|
|
|
|
<div className="bg-muted/30 flex h-32 items-center justify-center rounded-lg border">
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">표시할 데이터가 없습니다</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2025-10-28 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|