ERP-node/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx

855 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { getApiUrl } from "@/lib/utils/apiUrl";
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-muted", text: "text-foreground", border: "border-border" },
green: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
};
/**
* 통계 카드 위젯 (다중 데이터 소스 지원)
* - 여러 REST API 연결 가능
* - 여러 Database 연결 가능
* - REST API + Database 혼합 가능
* - 데이터 자동 병합 후 집계
*/
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
const [metrics, setMetrics] = useState<any[]>([]);
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
const [selectedMetric, setSelectedMetric] = useState<any | null>(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 && result.rows) {
const rows = result.rows;
if (rows.length > 0) {
const columns = result.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;
// REST API 호출 (백엔드 프록시 사용)
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: groupByDS.endpoint,
method: "GET",
headers: (groupByDS as any).headers || {},
}),
});
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);
}
}
}
}, [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;
// 🎯 간단한 쿼리도 잘 작동하도록 개선된 로직
if (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) => !numericColumns.includes(col));
// 🎯 케이스 0: 1행인데 숫자 컬럼이 여러 개 → 각 컬럼을 별도 카드로
if (rows.length === 1 && numericColumns.length > 1) {
// 예: SELECT COUNT(*) AS 전체, SUM(...) AS 배송중, ...
numericColumns.forEach((col) => {
allMetrics.push({
label: col, // 컬럼명이 라벨
value: Number(firstRow[col]) || 0,
field: col,
aggregation: "custom",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: [firstRow],
});
});
}
// 🎯 케이스 1: 컬럼이 2개 (라벨 + 값) → 가장 간단한 형태
else if (columns.length === 2) {
const labelCol = columns[0];
const valueCol = columns[1];
rows.forEach((row) => {
allMetrics.push({
label: String(row[labelCol] || ""),
value: Number(row[valueCol]) || 0,
field: valueCol,
aggregation: "custom",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: [row],
});
});
}
// 🎯 케이스 2: 숫자 컬럼이 1개 이상 있음 → 집계된 데이터
else if (numericColumns.length > 0) {
rows.forEach((row, index) => {
// 라벨: 첫 번째 문자열 컬럼 (없으면 첫 번째 컬럼)
const labelField = stringColumns[0] || columns[0];
const label = String(row[labelField] || `항목 ${index + 1}`);
// 값: 첫 번째 숫자 컬럼
const valueField = numericColumns[0];
const value = Number(row[valueField]) || 0;
allMetrics.push({
label: label,
value: value,
field: valueField,
aggregation: "custom",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: [row],
});
});
}
// 🎯 케이스 3: 숫자 컬럼이 없음 → 마지막 컬럼 기준으로 카운트
else {
const aggregateField = columns[columns.length - 1];
const countMap = new Map<string, number>();
rows.forEach((row) => {
const value = String(row[aggregateField] || "기타");
countMap.set(value, (countMap.get(value) || 0) + 1);
});
countMap.forEach((count, label) => {
allMetrics.push({
label: label,
value: count,
field: aggregateField,
aggregation: "count",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: rows.filter((row) => String(row[aggregateField]) === label),
});
});
// 전체 개수도 추가
allMetrics.push({
label: "전체",
value: rows.length,
field: "count",
aggregation: "count",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: rows,
});
}
}
// 🎯 행이 많을 때도 간단하게 처리
else if (rows.length > 100) {
// 행이 많으면 총 개수만 표시
// console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
const firstRow = rows[0];
const columns = Object.keys(firstRow);
// 데이터 소스에서 선택된 컬럼 가져오기
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
(ds) => ds.name === sourceName || (result.status === "fulfilled" && 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("<?xml") || text.trim().startsWith("<result>")) {
// 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<any[]> => {
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(getApiUrl("/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<any[]> => {
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<string, unknown>[];
};
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 (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background 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-muted-foreground"> ...</p>
</div>
</div>
);
}
// 에러 상태 (원본 스타일)
if (error) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background p-2">
<div className="text-center">
<p className="text-sm text-destructive"> {error}</p>
<button
onClick={handleManualRefresh}
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
>
</button>
</div>
</div>
);
}
// 데이터 소스 없음 (원본 스타일)
if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background p-2">
<p className="text-sm text-muted-foreground"> </p>
</div>
);
}
// 메트릭 설정 없음 (원본 스타일)
if (metricConfig.length === 0 && !isGroupByMode) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background p-2">
<p className="text-sm text-muted-foreground"> </p>
</div>
);
}
// 메인 렌더링 (원본 스타일 - 심플하게)
return (
<div className="flex h-full w-full flex-col bg-background p-2">
{/* 콘텐츠 영역 - 스크롤 가능하도록 개선 */}
<div
className="grid w-full gap-2 overflow-y-auto"
style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}
>
{/* 그룹별 카드 (활성화 시) */}
{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-foreground">{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-foreground">{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>
</div>
);
}