794 lines
28 KiB
TypeScript
794 lines
28 KiB
TypeScript
/**
|
||
* 커스텀 통계 카드 위젯
|
||
* - 쿼리 결과에서 숫자 데이터를 자동으로 감지하여 통계 표시
|
||
* - 합계, 평균, 비율 등 자동 계산
|
||
* - 처리량, 효율, 가동설비, 재고점유율 등 다양한 통계 지원
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import React, { useState, useEffect } from "react";
|
||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||
|
||
interface CustomStatsWidgetProps {
|
||
element?: DashboardElement;
|
||
refreshInterval?: number;
|
||
}
|
||
|
||
interface StatItem {
|
||
label: string;
|
||
value: number;
|
||
unit: string;
|
||
color: string;
|
||
icon: string;
|
||
}
|
||
|
||
export default function CustomStatsWidget({ element, refreshInterval = 60000 }: CustomStatsWidgetProps) {
|
||
// console.log("🚀 CustomStatsWidget 마운트:", {
|
||
// elementId: element?.id,
|
||
// query: element?.dataSource?.query?.substring(0, 50) + "...",
|
||
// hasDataSource: !!element?.dataSource,
|
||
// });
|
||
|
||
const [allStats, setAllStats] = useState<StatItem[]>([]); // 모든 통계
|
||
const [stats, setStats] = useState<StatItem[]>([]); // 표시할 통계
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [selectedStats, setSelectedStats] = useState<string[]>([]); // 선택된 통계 라벨
|
||
const selectedStatsRef = React.useRef<string[]>([]); // 현재 선택된 통계를 추적하는 ref
|
||
const isInitializedRef = React.useRef(false); // 초기화 여부 추적
|
||
const lastQueryRef = React.useRef<string>(""); // 마지막 쿼리 추적
|
||
|
||
// localStorage 키 생성 (쿼리 기반으로 고유하게 - 편집/보기 모드 공유)
|
||
const queryHash = element?.dataSource?.query
|
||
? btoa(element.dataSource.query) // 전체 쿼리를 base64로 인코딩
|
||
: "default";
|
||
const storageKey = `custom-stats-widget-${queryHash}`;
|
||
|
||
// console.log("🔑 storageKey:", storageKey, "(쿼리:", element?.dataSource?.query?.substring(0, 30) + "...)");
|
||
|
||
// 쿼리가 변경되면 초기화 상태 리셋
|
||
React.useEffect(() => {
|
||
const currentQuery = element?.dataSource?.query || "";
|
||
if (currentQuery !== lastQueryRef.current) {
|
||
isInitializedRef.current = false;
|
||
lastQueryRef.current = currentQuery;
|
||
}
|
||
}, [element?.dataSource?.query]);
|
||
|
||
// selectedStats 변경 시 ref 업데이트
|
||
React.useEffect(() => {
|
||
selectedStatsRef.current = selectedStats;
|
||
}, [selectedStats]);
|
||
|
||
// 데이터 로드
|
||
const loadData = React.useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
// 쿼리가 설정되어 있지 않으면 안내 메시지만 표시
|
||
if (!element?.dataSource?.query) {
|
||
setError("쿼리를 설정해주세요");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 쿼리 실행하여 통계 계산
|
||
const token = localStorage.getItem("authToken");
|
||
const response = await fetch("/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",
|
||
externalConnectionId: element.dataSource.externalConnectionId,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||
|
||
const result = await response.json();
|
||
|
||
if (!result.success || !result.data?.rows) {
|
||
throw new Error(result.message || "데이터 로드 실패");
|
||
}
|
||
|
||
const data = result.data.rows || [];
|
||
|
||
if (data.length === 0) {
|
||
setStats([]);
|
||
return;
|
||
}
|
||
|
||
const firstRow = data[0];
|
||
const statsItems: StatItem[] = [];
|
||
|
||
// 1. 총 건수 (항상 표시)
|
||
statsItems.push({
|
||
label: "총 건수",
|
||
value: data.length,
|
||
unit: "건",
|
||
color: "indigo",
|
||
icon: "📊",
|
||
});
|
||
|
||
// 2. 모든 숫자 컬럼 자동 감지
|
||
const numericColumns: { [key: string]: { sum: number; avg: number; count: number } } = {};
|
||
|
||
Object.keys(firstRow).forEach((key) => {
|
||
const value = firstRow[key];
|
||
// 숫자로 변환 가능한 컬럼만 선택 (id, order 같은 식별자 제외)
|
||
if (
|
||
value !== null &&
|
||
!isNaN(parseFloat(value)) &&
|
||
!key.toLowerCase().includes("id") &&
|
||
!key.toLowerCase().includes("order") &&
|
||
key.toLowerCase() !== "id"
|
||
) {
|
||
const validValues = data
|
||
.map((item: any) => parseFloat(item[key]))
|
||
.filter((v: number) => !isNaN(v) && v !== 0);
|
||
|
||
if (validValues.length > 0) {
|
||
const sum = validValues.reduce((acc: number, v: number) => acc + v, 0);
|
||
numericColumns[key] = {
|
||
sum,
|
||
avg: sum / validValues.length,
|
||
count: validValues.length,
|
||
};
|
||
}
|
||
}
|
||
});
|
||
|
||
// 3. 컬럼명 한글 번역 매핑
|
||
const columnNameTranslation: { [key: string]: string } = {
|
||
// 일반
|
||
id: "ID",
|
||
name: "이름",
|
||
title: "제목",
|
||
description: "설명",
|
||
status: "상태",
|
||
type: "유형",
|
||
category: "카테고리",
|
||
date: "날짜",
|
||
time: "시간",
|
||
created_at: "생성일",
|
||
updated_at: "수정일",
|
||
deleted_at: "삭제일",
|
||
|
||
// 물류/운송
|
||
tracking_number: "운송장 번호",
|
||
customer: "고객",
|
||
origin: "출발지",
|
||
destination: "목적지",
|
||
estimated_delivery: "예상 도착",
|
||
actual_delivery: "실제 도착",
|
||
delay_reason: "지연 사유",
|
||
priority: "우선순위",
|
||
cargo_weight: "화물 중량",
|
||
total_weight: "총 중량",
|
||
weight: "중량",
|
||
distance: "거리",
|
||
total_distance: "총 거리",
|
||
delivery_time: "배송 시간",
|
||
delivery_duration: "배송 소요시간",
|
||
is_on_time: "정시 도착 여부",
|
||
on_time: "정시",
|
||
|
||
// 수량/금액
|
||
quantity: "수량",
|
||
qty: "수량",
|
||
amount: "금액",
|
||
price: "가격",
|
||
cost: "비용",
|
||
fee: "수수료",
|
||
total: "합계",
|
||
sum: "총합",
|
||
|
||
// 비율/효율
|
||
rate: "비율",
|
||
ratio: "비율",
|
||
percent: "퍼센트",
|
||
percentage: "백분율",
|
||
efficiency: "효율",
|
||
|
||
// 생산/처리
|
||
throughput: "처리량",
|
||
output: "산출량",
|
||
production: "생산량",
|
||
volume: "용량",
|
||
|
||
// 재고/설비
|
||
stock: "재고",
|
||
inventory: "재고",
|
||
equipment: "설비",
|
||
facility: "시설",
|
||
machine: "기계",
|
||
|
||
// 평가
|
||
score: "점수",
|
||
rating: "평점",
|
||
point: "점수",
|
||
grade: "등급",
|
||
|
||
// 기타
|
||
temperature: "온도",
|
||
temp: "온도",
|
||
speed: "속도",
|
||
velocity: "속도",
|
||
count: "개수",
|
||
number: "번호",
|
||
};
|
||
|
||
// 4. 키워드 기반 자동 라벨링 및 단위 설정
|
||
const columnConfig: {
|
||
[key: string]: {
|
||
keywords: string[];
|
||
unit: string;
|
||
color: string;
|
||
icon: string;
|
||
aggregation: "sum" | "avg" | "max" | "min"; // 집계 방식
|
||
koreanLabel?: string; // 한글 라벨
|
||
};
|
||
} = {
|
||
// 무게/중량 - 합계
|
||
weight: {
|
||
keywords: ["weight", "cargo_weight", "total_weight", "tonnage", "ton"],
|
||
unit: "톤",
|
||
color: "green",
|
||
icon: "⚖️",
|
||
aggregation: "sum",
|
||
koreanLabel: "총 운송량",
|
||
},
|
||
// 거리 - 합계
|
||
distance: {
|
||
keywords: ["distance", "total_distance", "km", "kilometer"],
|
||
unit: "km",
|
||
color: "blue",
|
||
icon: "🛣️",
|
||
aggregation: "sum",
|
||
koreanLabel: "누적 거리",
|
||
},
|
||
// 시간/기간 - 평균
|
||
time: {
|
||
keywords: ["time", "duration", "delivery_time", "delivery_duration", "hour", "minute"],
|
||
unit: "분",
|
||
color: "orange",
|
||
icon: "⏱️",
|
||
aggregation: "avg",
|
||
koreanLabel: "평균 배송시간",
|
||
},
|
||
// 수량/개수 - 합계
|
||
quantity: {
|
||
keywords: ["quantity", "qty", "count", "number"],
|
||
unit: "개",
|
||
color: "purple",
|
||
icon: "📦",
|
||
aggregation: "sum",
|
||
koreanLabel: "총 수량",
|
||
},
|
||
// 금액/가격 - 합계
|
||
amount: {
|
||
keywords: ["amount", "price", "cost", "fee", "total", "sum"],
|
||
unit: "원",
|
||
color: "yellow",
|
||
icon: "💰",
|
||
aggregation: "sum",
|
||
koreanLabel: "총 금액",
|
||
},
|
||
// 비율/퍼센트 - 평균
|
||
rate: {
|
||
keywords: ["rate", "ratio", "percent", "efficiency", "%"],
|
||
unit: "%",
|
||
color: "cyan",
|
||
icon: "📈",
|
||
aggregation: "avg",
|
||
koreanLabel: "평균 비율",
|
||
},
|
||
// 처리량 - 합계
|
||
throughput: {
|
||
keywords: ["throughput", "output", "production", "volume"],
|
||
unit: "개",
|
||
color: "pink",
|
||
icon: "⚡",
|
||
aggregation: "sum",
|
||
koreanLabel: "총 처리량",
|
||
},
|
||
// 재고 - 평균 (현재 재고는 평균이 의미있음)
|
||
stock: {
|
||
keywords: ["stock", "inventory"],
|
||
unit: "개",
|
||
color: "teal",
|
||
icon: "📦",
|
||
aggregation: "avg",
|
||
koreanLabel: "평균 재고",
|
||
},
|
||
// 설비/장비 - 평균
|
||
equipment: {
|
||
keywords: ["equipment", "facility", "machine"],
|
||
unit: "대",
|
||
color: "gray",
|
||
icon: "🏭",
|
||
aggregation: "avg",
|
||
koreanLabel: "평균 가동 설비",
|
||
},
|
||
// 점수/평점 - 평균
|
||
score: {
|
||
keywords: ["score", "rating", "point", "grade"],
|
||
unit: "점",
|
||
color: "indigo",
|
||
icon: "⭐",
|
||
aggregation: "avg",
|
||
koreanLabel: "평균 점수",
|
||
},
|
||
// 온도 - 평균
|
||
temperature: {
|
||
keywords: ["temp", "temperature", "degree"],
|
||
unit: "°C",
|
||
color: "red",
|
||
icon: "🌡️",
|
||
aggregation: "avg",
|
||
koreanLabel: "평균 온도",
|
||
},
|
||
// 속도 - 평균
|
||
speed: {
|
||
keywords: ["speed", "velocity"],
|
||
unit: "km/h",
|
||
color: "blue",
|
||
icon: "🚀",
|
||
aggregation: "avg",
|
||
koreanLabel: "평균 속도",
|
||
},
|
||
};
|
||
|
||
// 4. 각 숫자 컬럼을 통계 카드로 변환
|
||
Object.entries(numericColumns).forEach(([key, stats]) => {
|
||
let label = key;
|
||
let unit = "";
|
||
let color = "gray";
|
||
let icon = "📊";
|
||
let aggregation: "sum" | "avg" | "max" | "min" = "sum"; // 기본값은 합계
|
||
let matchedConfig = null;
|
||
|
||
// 키워드 매칭으로 라벨, 단위, 색상, 집계방식 자동 설정
|
||
for (const [configKey, config] of Object.entries(columnConfig)) {
|
||
if (config.keywords.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||
unit = config.unit;
|
||
color = config.color;
|
||
icon = config.icon;
|
||
aggregation = config.aggregation;
|
||
matchedConfig = config;
|
||
|
||
// 한글 라벨 사용 또는 자동 변환
|
||
if (config.koreanLabel) {
|
||
label = config.koreanLabel;
|
||
} else {
|
||
// 집계 방식에 따라 접두어 추가
|
||
const prefix = aggregation === "avg" ? "평균 " : aggregation === "sum" ? "총 " : "";
|
||
label =
|
||
prefix +
|
||
key
|
||
.replace(/_/g, " ")
|
||
.replace(/([A-Z])/g, " $1")
|
||
.trim();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 매칭되지 않은 경우 기본 라벨 생성
|
||
if (!matchedConfig) {
|
||
// 컬럼명 번역 시도
|
||
const translatedName = columnNameTranslation[key.toLowerCase()];
|
||
|
||
if (translatedName) {
|
||
// 번역된 이름이 있으면 사용
|
||
label = translatedName;
|
||
} else {
|
||
// 컬럼명에 avg, average, mean이 포함되면 평균으로 간주
|
||
if (
|
||
key.toLowerCase().includes("avg") ||
|
||
key.toLowerCase().includes("average") ||
|
||
key.toLowerCase().includes("mean")
|
||
) {
|
||
aggregation = "avg";
|
||
|
||
// 언더스코어로 분리된 각 단어 번역 시도
|
||
const cleanKey = key
|
||
.replace(/avg|average|mean/gi, "")
|
||
.replace(/_/g, " ")
|
||
.trim();
|
||
const words = cleanKey.split(/[_\s]+/);
|
||
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
|
||
label = "평균 " + translatedWords.join(" ");
|
||
}
|
||
// total, sum이 포함되면 합계로 간주
|
||
else if (key.toLowerCase().includes("total") || key.toLowerCase().includes("sum")) {
|
||
aggregation = "sum";
|
||
|
||
// 언더스코어로 분리된 각 단어 번역 시도
|
||
const cleanKey = key
|
||
.replace(/total|sum/gi, "")
|
||
.replace(/_/g, " ")
|
||
.trim();
|
||
const words = cleanKey.split(/[_\s]+/);
|
||
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
|
||
label = "총 " + translatedWords.join(" ");
|
||
}
|
||
// 기본값 - 각 단어별로 번역 시도
|
||
else {
|
||
const words = key.split(/[_\s]+/);
|
||
const translatedWords = words.map((word) => {
|
||
const translated = columnNameTranslation[word.toLowerCase()];
|
||
if (translated) {
|
||
return translated;
|
||
}
|
||
// 번역이 없으면 첫 글자 대문자로
|
||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||
});
|
||
label = translatedWords.join(" ");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 집계 방식에 따라 값 선택
|
||
let value: number;
|
||
switch (aggregation) {
|
||
case "avg":
|
||
value = stats.avg;
|
||
break;
|
||
case "sum":
|
||
value = stats.sum;
|
||
break;
|
||
case "max":
|
||
value = Math.max(...data.map((item: any) => parseFloat(item[key]) || 0));
|
||
break;
|
||
case "min":
|
||
value = Math.min(...data.map((item: any) => parseFloat(item[key]) || 0));
|
||
break;
|
||
default:
|
||
value = stats.sum;
|
||
}
|
||
|
||
statsItems.push({
|
||
label,
|
||
value,
|
||
unit,
|
||
color,
|
||
icon,
|
||
});
|
||
});
|
||
|
||
// 5. Boolean 컬럼 비율 계산 (정시도착률, 성공률 등)
|
||
const booleanMapping: { [key: string]: string } = {
|
||
is_on_time: "정시 도착률",
|
||
on_time: "정시 도착률",
|
||
success: "성공률",
|
||
completed: "완료율",
|
||
delivered: "배송 완료율",
|
||
approved: "승인률",
|
||
};
|
||
|
||
const addedBooleanLabels = new Set<string>(); // 중복 방지
|
||
|
||
Object.keys(firstRow).forEach((key) => {
|
||
const lowerKey = key.toLowerCase();
|
||
const matchedKey = Object.keys(booleanMapping).find((k) => lowerKey.includes(k));
|
||
|
||
if (matchedKey) {
|
||
const label = booleanMapping[matchedKey];
|
||
|
||
// 이미 추가된 라벨이면 스킵
|
||
if (addedBooleanLabels.has(label)) {
|
||
return;
|
||
}
|
||
|
||
const validItems = data.filter((item: any) => item[key] !== null && item[key] !== undefined);
|
||
|
||
if (validItems.length > 0) {
|
||
const trueCount = validItems.filter((item: any) => {
|
||
const val = item[key];
|
||
return val === true || val === "true" || val === 1 || val === "1" || val === "Y";
|
||
}).length;
|
||
|
||
const rate = (trueCount / validItems.length) * 100;
|
||
|
||
statsItems.push({
|
||
label,
|
||
value: rate,
|
||
unit: "%",
|
||
color: "purple",
|
||
icon: "✅",
|
||
});
|
||
|
||
addedBooleanLabels.add(label);
|
||
}
|
||
}
|
||
});
|
||
|
||
// console.log("📊 생성된 통계 항목:", statsItems.map(s => s.label));
|
||
setAllStats(statsItems);
|
||
|
||
// 초기화가 아직 안됐으면 localStorage에서 설정 불러오기
|
||
if (!isInitializedRef.current) {
|
||
const saved = localStorage.getItem(storageKey);
|
||
// console.log("💾 저장된 설정:", saved);
|
||
|
||
if (saved) {
|
||
try {
|
||
const savedLabels = JSON.parse(saved);
|
||
// console.log("✅ 저장된 라벨:", savedLabels);
|
||
|
||
const filtered = statsItems.filter((s) => savedLabels.includes(s.label));
|
||
// console.log("🔍 필터링된 통계:", filtered.map(s => s.label));
|
||
// console.log(`📊 일치율: ${filtered.length}/${savedLabels.length} (${Math.round(filtered.length / savedLabels.length * 100)}%)`);
|
||
|
||
// 50% 이상 일치하면 저장된 설정 사용
|
||
const matchRate = filtered.length / savedLabels.length;
|
||
if (matchRate >= 0.5 && filtered.length > 0) {
|
||
setStats(filtered);
|
||
// 실제 표시되는 라벨로 업데이트
|
||
const actualLabels = filtered.map((s) => s.label);
|
||
setSelectedStats(actualLabels);
|
||
selectedStatsRef.current = actualLabels;
|
||
// localStorage도 업데이트하여 다음에는 정확히 일치하도록
|
||
localStorage.setItem(storageKey, JSON.stringify(actualLabels));
|
||
// console.log(`✅ ${filtered.length}개 통계 표시 (저장된 설정 기반)`);
|
||
} else {
|
||
// 일치율이 낮으면 처음 6개 표시하고 localStorage 업데이트
|
||
// console.warn(`⚠️ 일치율 ${Math.round(matchRate * 100)}% - 기본값 사용`);
|
||
const defaultLabels = statsItems.slice(0, 6).map((s) => s.label);
|
||
setStats(statsItems.slice(0, 6));
|
||
setSelectedStats(defaultLabels);
|
||
selectedStatsRef.current = defaultLabels;
|
||
localStorage.setItem(storageKey, JSON.stringify(defaultLabels));
|
||
}
|
||
} catch (e) {
|
||
// console.error("❌ 설정 파싱 실패:", e);
|
||
const defaultLabels = statsItems.slice(0, 6).map((s) => s.label);
|
||
setStats(statsItems.slice(0, 6));
|
||
setSelectedStats(defaultLabels);
|
||
selectedStatsRef.current = defaultLabels;
|
||
}
|
||
} else {
|
||
// 저장된 설정이 없으면 처음 6개 표시
|
||
// console.log("🆕 저장된 설정 없음. 기본값 사용");
|
||
const defaultLabels = statsItems.slice(0, 6).map((s) => s.label);
|
||
setStats(statsItems.slice(0, 6));
|
||
setSelectedStats(defaultLabels);
|
||
selectedStatsRef.current = defaultLabels;
|
||
}
|
||
isInitializedRef.current = true;
|
||
} else {
|
||
// 이미 초기화됐으면 현재 선택된 통계 유지
|
||
const currentSelected = selectedStatsRef.current;
|
||
// console.log("🔄 현재 선택된 통계:", currentSelected);
|
||
|
||
if (currentSelected.length > 0) {
|
||
const filtered = statsItems.filter((s) => currentSelected.includes(s.label));
|
||
// console.log("🔍 필터링 결과:", filtered.map(s => s.label));
|
||
|
||
if (filtered.length > 0) {
|
||
setStats(filtered);
|
||
} else {
|
||
// console.warn("⚠️ 선택된 항목과 일치하는 통계가 없음");
|
||
setStats(statsItems.slice(0, 6));
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// console.error("통계 로드 실패:", err);
|
||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [element?.dataSource, storageKey]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
const interval = setInterval(loadData, refreshInterval);
|
||
return () => clearInterval(interval);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [refreshInterval, element?.dataSource]);
|
||
|
||
// 색상 매핑
|
||
const getColorClasses = (color: string) => {
|
||
const colorMap: { [key: string]: { bg: string; text: string } } = {
|
||
indigo: { bg: "bg-indigo-50", text: "text-indigo-600" },
|
||
green: { bg: "bg-green-50", text: "text-green-600" },
|
||
blue: { bg: "bg-blue-50", text: "text-blue-600" },
|
||
purple: { bg: "bg-purple-50", text: "text-purple-600" },
|
||
orange: { bg: "bg-orange-50", text: "text-orange-600" },
|
||
yellow: { bg: "bg-yellow-50", text: "text-yellow-600" },
|
||
cyan: { bg: "bg-cyan-50", text: "text-cyan-600" },
|
||
pink: { bg: "bg-pink-50", text: "text-pink-600" },
|
||
teal: { bg: "bg-teal-50", text: "text-teal-600" },
|
||
gray: { bg: "bg-gray-50", text: "text-gray-600" },
|
||
};
|
||
return colorMap[color] || colorMap.gray;
|
||
};
|
||
|
||
if (isLoading && stats.length === 0) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||
<div className="text-center">
|
||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-4xl">⚠️</div>
|
||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
||
<button
|
||
onClick={loadData}
|
||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||
>
|
||
다시 시도
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (stats.length === 0) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-4xl">📊</div>
|
||
<div className="text-sm font-medium text-gray-600">데이터 없음</div>
|
||
<div className="mt-2 text-xs text-gray-500">쿼리를 실행하여 통계를 확인하세요</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handleToggleStat = (label: string) => {
|
||
setSelectedStats((prev) => {
|
||
const newStats = prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label];
|
||
// console.log("🔘 토글:", label, "→", newStats.length + "개 선택");
|
||
return newStats;
|
||
});
|
||
};
|
||
|
||
const handleApplySettings = () => {
|
||
// console.log("💾 설정 적용:", selectedStats);
|
||
// console.log("📊 전체 통계:", allStats.map(s => s.label));
|
||
|
||
const filtered = allStats.filter((s) => selectedStats.includes(s.label));
|
||
// console.log("✅ 필터링 결과:", filtered.map(s => s.label));
|
||
|
||
setStats(filtered);
|
||
selectedStatsRef.current = selectedStats; // ref도 업데이트
|
||
setShowSettings(false);
|
||
|
||
// localStorage에 설정 저장
|
||
localStorage.setItem(storageKey, JSON.stringify(selectedStats));
|
||
// console.log("💾 localStorage 저장 완료:", selectedStats.length + "개");
|
||
};
|
||
|
||
// 렌더링 시 상태 로그
|
||
// console.log("🎨 렌더링 - stats:", stats.map(s => s.label));
|
||
// console.log("🎨 렌더링 - selectedStats:", selectedStats);
|
||
// console.log("🎨 렌더링 - allStats:", allStats.map(s => s.label));
|
||
|
||
return (
|
||
<div className="relative flex h-full flex-col bg-white">
|
||
{/* 헤더 영역 */}
|
||
<div className="flex items-center justify-between border-b bg-gray-50 px-4 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-lg">📊</span>
|
||
<span className="text-sm font-medium text-gray-700">커스텀 통계</span>
|
||
<span className="text-xs text-gray-500">({stats.length}개 표시 중)</span>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
// 설정 모달 열 때 현재 표시 중인 통계로 동기화
|
||
const currentLabels = stats.map((s) => s.label);
|
||
// console.log("⚙️ 설정 모달 열기 - 현재 표시 중:", currentLabels);
|
||
setSelectedStats(currentLabels);
|
||
setShowSettings(true);
|
||
}}
|
||
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
|
||
title="표시할 통계 선택"
|
||
>
|
||
<span>⚙️</span>
|
||
<span>설정</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 통계 카드 */}
|
||
<div className="flex flex-1 items-center justify-center p-6">
|
||
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||
{stats.map((stat, index) => {
|
||
const colors = getColorClasses(stat.color);
|
||
return (
|
||
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
|
||
<div className="text-sm text-gray-600">{stat.label}</div>
|
||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
|
||
<span className="ml-1 text-lg">{stat.unit}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 설정 모달 */}
|
||
{showSettings && (
|
||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50">
|
||
<div className="max-h-[80%] w-[90%] max-w-md overflow-auto rounded-lg bg-white p-6 shadow-xl">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h3 className="text-lg font-bold">표시할 통계 선택</h3>
|
||
<button onClick={() => setShowSettings(false)} className="text-2xl text-gray-500 hover:text-gray-700">
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mb-4 text-sm text-gray-600">표시하고 싶은 통계를 선택하세요 (최대 제한 없음)</div>
|
||
|
||
<div className="space-y-2">
|
||
{allStats.map((stat, index) => {
|
||
const isChecked = selectedStats.includes(stat.label);
|
||
return (
|
||
<label
|
||
key={index}
|
||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${
|
||
isChecked ? "border-blue-500 bg-blue-50" : "hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isChecked}
|
||
onChange={() => {
|
||
// console.log("📋 체크박스 클릭:", stat.label, "현재:", isChecked);
|
||
handleToggleStat(stat.label);
|
||
}}
|
||
className="h-5 w-5"
|
||
/>
|
||
<div className="flex-1">
|
||
<div className="font-medium">{stat.label}</div>
|
||
<div className="text-sm text-gray-500">단위: {stat.unit}</div>
|
||
</div>
|
||
{isChecked && <span className="text-blue-600">✓</span>}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-6 flex gap-2">
|
||
<button
|
||
onClick={handleApplySettings}
|
||
className="flex-1 rounded-lg bg-blue-500 py-2 text-white hover:bg-blue-600"
|
||
>
|
||
적용 ({selectedStats.length}개 선택)
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
// console.log("❌ 설정 취소");
|
||
setShowSettings(false);
|
||
}}
|
||
className="rounded-lg border px-4 py-2 hover:bg-gray-50"
|
||
>
|
||
취소
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|