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

506 lines
17 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) {
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[]>([]); // 선택된 통계 라벨
// 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 (
<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) => {
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 (
<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={() => 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.icon} {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) => (
<label
key={index}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-gray-50"
>
<input
type="checkbox"
checked={selectedStats.includes(stat.label)}
onChange={() => handleToggleStat(stat.label)}
className="h-5 w-5"
/>
<span className="text-xl">{stat.icon}</span>
<div className="flex-1">
<div className="font-medium">{stat.label}</div>
<div className="text-sm text-gray-500">: {stat.unit}</div>
</div>
</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={() => setShowSettings(false)}
className="rounded-lg border px-4 py-2 hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}