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

794 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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