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

506 lines
17 KiB
TypeScript
Raw Normal View History

/**
*
* -
* - , ,
* - , , ,
*/
"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>
);
}