/** * 커스텀 통계 카드 위젯 * - 쿼리 결과에서 숫자 데이터를 자동으로 감지하여 통계 표시 * - 합계, 평균, 비율 등 자동 계산 * - 처리량, 효율, 가동설비, 재고점유율 등 다양한 통계 지원 */ "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([]); // 모든 통계 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("/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 ( ); })}
)}
); }