/** * 커스텀 통계 카드 위젯 * - 쿼리 결과에서 숫자 데이터를 자동으로 감지하여 통계 표시 * - 합계, 평균, 비율 등 자동 계산 * - 처리량, 효율, 가동설비, 재고점유율 등 다양한 통계 지원 */ "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) { 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([]); // 선택된 통계 라벨 // localStorage 키 생성 (위젯별로 고유하게) const storageKey = `custom-stats-widget-${element?.id || "default"}`; // 초기 로드 시 저장된 설정 불러오기 React.useEffect(() => { const saved = localStorage.getItem(storageKey); if (saved) { try { const parsed = JSON.parse(saved); setSelectedStats(parsed); } catch (e) { console.error("설정 로드 실패:", e); } } }, [storageKey]); // 데이터 로드 const loadData = 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 columnConfig: { [key: string]: { keywords: string[]; unit: string; color: string; icon: string; useAvg?: boolean; koreanLabel?: string; // 한글 라벨 }; } = { // 무게/중량 weight: { keywords: ["weight", "cargo_weight", "total_weight", "tonnage"], unit: "톤", color: "green", icon: "⚖️", koreanLabel: "총 운송량" }, // 거리 distance: { keywords: ["distance", "total_distance"], unit: "km", color: "blue", icon: "🛣️", koreanLabel: "누적 거리" }, // 시간/기간 time: { keywords: ["time", "duration", "delivery_time", "delivery_duration"], unit: "분", color: "orange", icon: "⏱️", useAvg: true, koreanLabel: "평균 배송시간" }, // 수량/개수 quantity: { keywords: ["quantity", "qty", "amount"], unit: "개", color: "purple", icon: "📦", koreanLabel: "총 수량" }, // 금액/가격 price: { keywords: ["price", "cost", "fee"], unit: "원", color: "yellow", icon: "💰", koreanLabel: "총 금액" }, // 비율/퍼센트 rate: { keywords: ["rate", "ratio", "percent", "efficiency"], unit: "%", color: "cyan", icon: "📈", useAvg: true, koreanLabel: "평균 비율" }, // 처리량 throughput: { keywords: ["throughput", "output", "production"], unit: "개", color: "pink", icon: "⚡", koreanLabel: "총 처리량" }, // 재고 stock: { keywords: ["stock", "inventory"], unit: "개", color: "teal", icon: "📦", koreanLabel: "재고 수량" }, // 설비/장비 equipment: { keywords: ["equipment", "facility", "machine"], unit: "대", color: "gray", icon: "🏭", koreanLabel: "가동 설비" }, }; // 4. 각 숫자 컬럼을 통계 카드로 변환 Object.entries(numericColumns).forEach(([key, stats]) => { let label = key; let unit = ""; let color = "gray"; let icon = "📊"; let useAvg = false; 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; useAvg = config.useAvg || false; matchedConfig = config; // 한글 라벨 사용 또는 자동 변환 label = config.koreanLabel || key .replace(/_/g, " ") .replace(/([A-Z])/g, " $1") .trim(); break; } } // 매칭되지 않은 경우 기본 라벨 생성 if (!matchedConfig) { label = key .replace(/_/g, " ") .replace(/([A-Z])/g, " $1") .trim() .split(" ") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } // 합계 또는 평균 선택 const value = useAvg ? stats.avg : 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: "승인률", }; Object.keys(firstRow).forEach((key) => { const lowerKey = key.toLowerCase(); const matchedKey = Object.keys(booleanMapping).find((k) => lowerKey.includes(k)); if (matchedKey) { 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: booleanMapping[matchedKey], value: rate, unit: "%", color: "purple", icon: "✅", }); } } }); setAllStats(statsItems); // 초기에는 모든 통계 표시 (최대 6개) if (selectedStats.length === 0) { setStats(statsItems.slice(0, 6)); setSelectedStats(statsItems.slice(0, 6).map((s) => s.label)); } else { // 선택된 통계만 표시 const filtered = statsItems.filter((s) => selectedStats.includes(s.label)); setStats(filtered); } } catch (err) { console.error("통계 로드 실패:", err); setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); } finally { setIsLoading(false); } }; 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) => { if (prev.includes(label)) { return prev.filter((l) => l !== label); } else { return [...prev, label]; } }); }; const handleApplySettings = () => { const filtered = allStats.filter((s) => selectedStats.includes(s.label)); setStats(filtered); setShowSettings(false); // localStorage에 설정 저장 localStorage.setItem(storageKey, JSON.stringify(selectedStats)); }; return (
{/* 헤더 영역 */}
📊 커스텀 통계 ({stats.length}개 표시 중)
{/* 통계 카드 */}
{stats.map((stat, index) => { const colors = getColorClasses(stat.color); return (
{stat.icon} {stat.label}
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()} {stat.unit}
); })}
{/* 설정 모달 */} {showSettings && (

표시할 통계 선택

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