/** * 커스텀 통계 카드 위젯 * - 쿼리 결과에서 숫자 데이터를 자동으로 감지하여 통계 표시 * - 합계, 평균, 비율 등 자동 계산 * - 처리량, 효율, 가동설비, 재고점유율 등 다양한 통계 지원 */ "use client"; import React, { useState, useEffect } from "react"; import { DashboardElement } from "@/components/admin/dashboard/types"; 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) { // console.log("🚀 CustomStatsWidget 마운트:", { // elementId: element?.id, // query: element?.dataSource?.query?.substring(0, 50) + "...", // hasDataSource: !!element?.dataSource, // }); const [allStats, setAllStats] = useState([]); // 모든 통계 const [stats, setStats] = useState([]); // 표시할 통계 const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [showSettings, setShowSettings] = useState(false); const [selectedStats, setSelectedStats] = useState([]); // 선택된 통계 라벨 const selectedStatsRef = React.useRef([]); // 현재 선택된 통계를 추적하는 ref const isInitializedRef = React.useRef(false); // 초기화 여부 추적 const lastQueryRef = React.useRef(""); // 마지막 쿼리 추적 // 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(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, }; } } }); // 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(); // 중복 방지 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 (
로딩 중...
); } if (error) { return (
⚠️
{error}
{!element?.dataSource?.query &&
쿼리를 설정하세요
}
); } if (stats.length === 0) { return (
📊
데이터 없음
쿼리를 실행하여 통계를 확인하세요
); } 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 (
{/* 헤더 영역 */}
📊 커스텀 통계 ({stats.length}개 표시 중)
{/* 통계 카드 */}
{stats.map((stat, index) => { const colors = getColorClasses(stat.color); return (
{stat.label}
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()} {stat.unit}
); })}
{/* 설정 모달 */} {showSettings && (

표시할 통계 선택

표시하고 싶은 통계를 선택하세요 (최대 제한 없음)
{allStats.map((stat, index) => { const isChecked = selectedStats.includes(stat.label); return ( ); })}
)}
); }