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

795 lines
28 KiB
TypeScript
Raw Normal View History

/**
*
* -
* - , ,
* - , , ,
*/
"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
2025-10-24 16:08:57 +09:00
import { getApiUrl } from "@/lib/utils/apiUrl";
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) {
2025-10-20 17:42:35 +09:00
// 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[]>([]); // 선택된 통계 라벨
2025-10-20 17:42:35 +09:00
const selectedStatsRef = React.useRef<string[]>([]); // 현재 선택된 통계를 추적하는 ref
const isInitializedRef = React.useRef(false); // 초기화 여부 추적
const lastQueryRef = React.useRef<string>(""); // 마지막 쿼리 추적
// localStorage 키 생성 (쿼리 기반으로 고유하게 - 편집/보기 모드 공유)
2025-10-22 13:40:15 +09:00
const queryHash = element?.dataSource?.query
2025-10-20 17:42:35 +09:00
? btoa(element.dataSource.query) // 전체 쿼리를 base64로 인코딩
: "default";
const storageKey = `custom-stats-widget-${queryHash}`;
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
// console.log("🔑 storageKey:", storageKey, "(쿼리:", element?.dataSource?.query?.substring(0, 30) + "...)");
// 쿼리가 변경되면 초기화 상태 리셋
React.useEffect(() => {
2025-10-20 17:42:35 +09:00
const currentQuery = element?.dataSource?.query || "";
if (currentQuery !== lastQueryRef.current) {
isInitializedRef.current = false;
lastQueryRef.current = currentQuery;
}
2025-10-20 17:42:35 +09:00
}, [element?.dataSource?.query]);
// selectedStats 변경 시 ref 업데이트
React.useEffect(() => {
selectedStatsRef.current = selectedStats;
}, [selectedStats]);
// 데이터 로드
2025-10-20 17:42:35 +09:00
const loadData = React.useCallback(async () => {
try {
setIsLoading(true);
setError(null);
// 쿼리가 설정되어 있지 않으면 안내 메시지만 표시
if (!element?.dataSource?.query) {
setError("쿼리를 설정해주세요");
setIsLoading(false);
return;
}
// 쿼리 실행하여 통계 계산
const token = localStorage.getItem("authToken");
2025-10-24 16:08:57 +09:00
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
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,
};
}
}
});
2025-10-20 17:42:35 +09:00
// 3. 컬럼명 한글 번역 매핑
const columnNameTranslation: { [key: string]: string } = {
// 일반
2025-10-22 13:40:15 +09:00
id: "ID",
name: "이름",
title: "제목",
description: "설명",
status: "상태",
type: "유형",
category: "카테고리",
date: "날짜",
time: "시간",
created_at: "생성일",
updated_at: "수정일",
deleted_at: "삭제일",
2025-10-20 17:42:35 +09:00
// 물류/운송
2025-10-22 13:40:15 +09:00
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: "정시",
2025-10-20 17:42:35 +09:00
// 수량/금액
2025-10-22 13:40:15 +09:00
quantity: "수량",
qty: "수량",
amount: "금액",
price: "가격",
cost: "비용",
fee: "수수료",
total: "합계",
sum: "총합",
2025-10-20 17:42:35 +09:00
// 비율/효율
2025-10-22 13:40:15 +09:00
rate: "비율",
ratio: "비율",
percent: "퍼센트",
percentage: "백분율",
efficiency: "효율",
2025-10-20 17:42:35 +09:00
// 생산/처리
2025-10-22 13:40:15 +09:00
throughput: "처리량",
output: "산출량",
production: "생산량",
volume: "용량",
2025-10-20 17:42:35 +09:00
// 재고/설비
2025-10-22 13:40:15 +09:00
stock: "재고",
inventory: "재고",
equipment: "설비",
facility: "시설",
machine: "기계",
2025-10-20 17:42:35 +09:00
// 평가
2025-10-22 13:40:15 +09:00
score: "점수",
rating: "평점",
point: "점수",
grade: "등급",
2025-10-20 17:42:35 +09:00
// 기타
2025-10-22 13:40:15 +09:00
temperature: "온도",
temp: "온도",
speed: "속도",
velocity: "속도",
count: "개수",
number: "번호",
2025-10-20 17:42:35 +09:00
};
// 4. 키워드 기반 자동 라벨링 및 단위 설정
const columnConfig: {
2025-10-22 13:40:15 +09:00
[key: string]: {
keywords: string[];
unit: string;
color: string;
icon: string;
2025-10-20 17:42:35 +09:00
aggregation: "sum" | "avg" | "max" | "min"; // 집계 방식
koreanLabel?: string; // 한글 라벨
};
} = {
2025-10-20 17:42:35 +09:00
// 무게/중량 - 합계
2025-10-22 13:40:15 +09:00
weight: {
keywords: ["weight", "cargo_weight", "total_weight", "tonnage", "ton"],
unit: "톤",
color: "green",
icon: "⚖️",
2025-10-20 17:42:35 +09:00
aggregation: "sum",
2025-10-22 13:40:15 +09:00
koreanLabel: "총 운송량",
},
2025-10-20 17:42:35 +09:00
// 거리 - 합계
2025-10-22 13:40:15 +09:00
distance: {
keywords: ["distance", "total_distance", "km", "kilometer"],
unit: "km",
color: "blue",
icon: "🛣️",
2025-10-20 17:42:35 +09:00
aggregation: "sum",
2025-10-22 13:40:15 +09:00
koreanLabel: "누적 거리",
},
2025-10-20 17:42:35 +09:00
// 시간/기간 - 평균
2025-10-22 13:40:15 +09:00
time: {
keywords: ["time", "duration", "delivery_time", "delivery_duration", "hour", "minute"],
unit: "분",
color: "orange",
icon: "⏱️",
2025-10-20 17:42:35 +09:00
aggregation: "avg",
2025-10-22 13:40:15 +09:00
koreanLabel: "평균 배송시간",
},
2025-10-20 17:42:35 +09:00
// 수량/개수 - 합계
2025-10-22 13:40:15 +09:00
quantity: {
keywords: ["quantity", "qty", "count", "number"],
unit: "개",
color: "purple",
icon: "📦",
2025-10-20 17:42:35 +09:00
aggregation: "sum",
2025-10-22 13:40:15 +09:00
koreanLabel: "총 수량",
},
2025-10-20 17:42:35 +09:00
// 금액/가격 - 합계
2025-10-22 13:40:15 +09:00
amount: {
keywords: ["amount", "price", "cost", "fee", "total", "sum"],
unit: "원",
color: "yellow",
icon: "💰",
2025-10-20 17:42:35 +09:00
aggregation: "sum",
2025-10-22 13:40:15 +09:00
koreanLabel: "총 금액",
},
2025-10-20 17:42:35 +09:00
// 비율/퍼센트 - 평균
2025-10-22 13:40:15 +09:00
rate: {
keywords: ["rate", "ratio", "percent", "efficiency", "%"],
unit: "%",
color: "cyan",
icon: "📈",
2025-10-20 17:42:35 +09:00
aggregation: "avg",
2025-10-22 13:40:15 +09:00
koreanLabel: "평균 비율",
},
2025-10-20 17:42:35 +09:00
// 처리량 - 합계
2025-10-22 13:40:15 +09:00
throughput: {
keywords: ["throughput", "output", "production", "volume"],
unit: "개",
color: "pink",
icon: "⚡",
2025-10-20 17:42:35 +09:00
aggregation: "sum",
2025-10-22 13:40:15 +09:00
koreanLabel: "총 처리량",
},
2025-10-20 17:42:35 +09:00
// 재고 - 평균 (현재 재고는 평균이 의미있음)
2025-10-22 13:40:15 +09:00
stock: {
keywords: ["stock", "inventory"],
unit: "개",
color: "teal",
icon: "📦",
2025-10-20 17:42:35 +09:00
aggregation: "avg",
2025-10-22 13:40:15 +09:00
koreanLabel: "평균 재고",
},
2025-10-20 17:42:35 +09:00
// 설비/장비 - 평균
2025-10-22 13:40:15 +09:00
equipment: {
keywords: ["equipment", "facility", "machine"],
unit: "대",
color: "gray",
icon: "🏭",
2025-10-20 17:42:35 +09:00
aggregation: "avg",
2025-10-22 13:40:15 +09:00
koreanLabel: "평균 가동 설비",
2025-10-20 17:42:35 +09:00
},
// 점수/평점 - 평균
score: {
keywords: ["score", "rating", "point", "grade"],
unit: "점",
color: "indigo",
icon: "⭐",
aggregation: "avg",
2025-10-22 13:40:15 +09:00
koreanLabel: "평균 점수",
2025-10-20 17:42:35 +09:00
},
// 온도 - 평균
temperature: {
keywords: ["temp", "temperature", "degree"],
unit: "°C",
color: "red",
icon: "🌡️",
aggregation: "avg",
2025-10-22 13:40:15 +09:00
koreanLabel: "평균 온도",
2025-10-20 17:42:35 +09:00
},
// 속도 - 평균
speed: {
keywords: ["speed", "velocity"],
unit: "km/h",
color: "blue",
icon: "🚀",
aggregation: "avg",
2025-10-22 13:40:15 +09:00
koreanLabel: "평균 속도",
},
};
// 4. 각 숫자 컬럼을 통계 카드로 변환
Object.entries(numericColumns).forEach(([key, stats]) => {
let label = key;
let unit = "";
let color = "gray";
let icon = "📊";
2025-10-20 17:42:35 +09:00
let aggregation: "sum" | "avg" | "max" | "min" = "sum"; // 기본값은 합계
let matchedConfig = null;
2025-10-20 17:42:35 +09:00
// 키워드 매칭으로 라벨, 단위, 색상, 집계방식 자동 설정
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;
2025-10-20 17:42:35 +09:00
aggregation = config.aggregation;
matchedConfig = config;
2025-10-22 13:40:15 +09:00
// 한글 라벨 사용 또는 자동 변환
2025-10-20 17:42:35 +09:00
if (config.koreanLabel) {
label = config.koreanLabel;
} else {
// 집계 방식에 따라 접두어 추가
const prefix = aggregation === "avg" ? "평균 " : aggregation === "sum" ? "총 " : "";
2025-10-22 13:40:15 +09:00
label =
prefix +
key
.replace(/_/g, " ")
.replace(/([A-Z])/g, " $1")
.trim();
2025-10-20 17:42:35 +09:00
}
break;
}
}
// 매칭되지 않은 경우 기본 라벨 생성
if (!matchedConfig) {
2025-10-20 17:42:35 +09:00
// 컬럼명 번역 시도
const translatedName = columnNameTranslation[key.toLowerCase()];
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
if (translatedName) {
// 번역된 이름이 있으면 사용
label = translatedName;
} else {
// 컬럼명에 avg, average, mean이 포함되면 평균으로 간주
2025-10-22 13:40:15 +09:00
if (
key.toLowerCase().includes("avg") ||
key.toLowerCase().includes("average") ||
key.toLowerCase().includes("mean")
) {
2025-10-20 17:42:35 +09:00
aggregation = "avg";
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
// 언더스코어로 분리된 각 단어 번역 시도
2025-10-22 13:40:15 +09:00
const cleanKey = key
.replace(/avg|average|mean/gi, "")
.replace(/_/g, " ")
.trim();
2025-10-20 17:42:35 +09:00
const words = cleanKey.split(/[_\s]+/);
2025-10-22 13:40:15 +09:00
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
2025-10-20 17:42:35 +09:00
label = "평균 " + translatedWords.join(" ");
2025-10-22 13:40:15 +09:00
}
2025-10-20 17:42:35 +09:00
// total, sum이 포함되면 합계로 간주
else if (key.toLowerCase().includes("total") || key.toLowerCase().includes("sum")) {
aggregation = "sum";
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
// 언더스코어로 분리된 각 단어 번역 시도
2025-10-22 13:40:15 +09:00
const cleanKey = key
.replace(/total|sum/gi, "")
.replace(/_/g, " ")
.trim();
2025-10-20 17:42:35 +09:00
const words = cleanKey.split(/[_\s]+/);
2025-10-22 13:40:15 +09:00
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
2025-10-20 17:42:35 +09:00
label = "총 " + translatedWords.join(" ");
}
// 기본값 - 각 단어별로 번역 시도
else {
const words = key.split(/[_\s]+/);
2025-10-22 13:40:15 +09:00
const translatedWords = words.map((word) => {
2025-10-20 17:42:35 +09:00
const translated = columnNameTranslation[word.toLowerCase()];
if (translated) {
return translated;
}
// 번역이 없으면 첫 글자 대문자로
return word.charAt(0).toUpperCase() + word.slice(1);
});
label = translatedWords.join(" ");
}
}
}
2025-10-20 17:42:35 +09:00
// 집계 방식에 따라 값 선택
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: "승인률",
};
2025-10-20 17:42:35 +09:00
const addedBooleanLabels = new Set<string>(); // 중복 방지
Object.keys(firstRow).forEach((key) => {
const lowerKey = key.toLowerCase();
const matchedKey = Object.keys(booleanMapping).find((k) => lowerKey.includes(k));
2025-10-22 13:40:15 +09:00
if (matchedKey) {
2025-10-20 17:42:35 +09:00
const label = booleanMapping[matchedKey];
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
// 이미 추가된 라벨이면 스킵
if (addedBooleanLabels.has(label)) {
return;
}
2025-10-22 13:40:15 +09:00
const validItems = data.filter((item: any) => item[key] !== null && item[key] !== undefined);
2025-10-22 13:40:15 +09:00
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;
2025-10-22 13:40:15 +09:00
const rate = (trueCount / validItems.length) * 100;
2025-10-22 13:40:15 +09:00
statsItems.push({
2025-10-20 17:42:35 +09:00
label,
value: rate,
unit: "%",
color: "purple",
icon: "✅",
});
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
addedBooleanLabels.add(label);
}
}
});
2025-10-20 17:42:35 +09:00
// console.log("📊 생성된 통계 항목:", statsItems.map(s => s.label));
setAllStats(statsItems);
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
// 초기화가 아직 안됐으면 localStorage에서 설정 불러오기
if (!isInitializedRef.current) {
const saved = localStorage.getItem(storageKey);
// console.log("💾 저장된 설정:", saved);
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
if (saved) {
try {
const savedLabels = JSON.parse(saved);
// console.log("✅ 저장된 라벨:", savedLabels);
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
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)}%)`);
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
// 50% 이상 일치하면 저장된 설정 사용
const matchRate = filtered.length / savedLabels.length;
if (matchRate >= 0.5 && filtered.length > 0) {
setStats(filtered);
// 실제 표시되는 라벨로 업데이트
2025-10-22 13:40:15 +09:00
const actualLabels = filtered.map((s) => s.label);
2025-10-20 17:42:35 +09:00
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 {
2025-10-20 17:42:35 +09:00
// 이미 초기화됐으면 현재 선택된 통계 유지
const currentSelected = selectedStatsRef.current;
// console.log("🔄 현재 선택된 통계:", currentSelected);
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
if (currentSelected.length > 0) {
const filtered = statsItems.filter((s) => currentSelected.includes(s.label));
// console.log("🔍 필터링 결과:", filtered.map(s => s.label));
2025-10-22 13:40:15 +09:00
2025-10-20 17:42:35 +09:00
if (filtered.length > 0) {
setStats(filtered);
} else {
// console.warn("⚠️ 선택된 항목과 일치하는 통계가 없음");
setStats(statsItems.slice(0, 6));
}
}
}
} catch (err) {
2025-10-20 17:42:35 +09:00
// console.error("통계 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
} finally {
setIsLoading(false);
}
2025-10-20 17:42:35 +09:00
}, [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>
2025-10-22 13:40:15 +09:00
{!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) => {
2025-10-22 13:40:15 +09:00
const newStats = prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label];
2025-10-20 17:42:35 +09:00
// console.log("🔘 토글:", label, "→", newStats.length + "개 선택");
return newStats;
});
};
const handleApplySettings = () => {
2025-10-20 17:42:35 +09:00
// console.log("💾 설정 적용:", selectedStats);
// console.log("📊 전체 통계:", allStats.map(s => s.label));
2025-10-22 13:40:15 +09:00
const filtered = allStats.filter((s) => selectedStats.includes(s.label));
2025-10-20 17:42:35 +09:00
// console.log("✅ 필터링 결과:", filtered.map(s => s.label));
2025-10-22 13:40:15 +09:00
setStats(filtered);
2025-10-20 17:42:35 +09:00
selectedStatsRef.current = selectedStats; // ref도 업데이트
setShowSettings(false);
2025-10-22 13:40:15 +09:00
// localStorage에 설정 저장
localStorage.setItem(storageKey, JSON.stringify(selectedStats));
2025-10-20 17:42:35 +09:00
// console.log("💾 localStorage 저장 완료:", selectedStats.length + "개");
};
2025-10-20 17:42:35 +09:00
// 렌더링 시 상태 로그
// 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
2025-10-20 17:42:35 +09:00
onClick={() => {
// 설정 모달 열 때 현재 표시 중인 통계로 동기화
2025-10-22 13:40:15 +09:00
const currentLabels = stats.map((s) => s.label);
2025-10-20 17:42:35 +09:00
// 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`}>
2025-10-22 13:40:15 +09:00
<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>
2025-10-22 13:40:15 +09:00
<div className="mb-4 text-sm text-gray-600"> ( )</div>
<div className="space-y-2">
2025-10-20 17:42:35 +09:00
{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
2025-10-20 17:42:35 +09:00
onClick={() => {
// console.log("❌ 설정 취소");
setShowSettings(false);
}}
className="rounded-lg border px-4 py-2 hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}