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

423 lines
15 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
2025-10-24 09:52:51 +09:00
import { getApiUrl } from "@/lib/utils/apiUrl";
interface CustomMetricWidgetProps {
element?: DashboardElement;
}
// 집계 함수 실행
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
if (rows.length === 0) return 0;
switch (aggregation) {
case "count":
return rows.length;
case "sum": {
return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0);
}
case "avg": {
const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0);
return rows.length > 0 ? sum / rows.length : 0;
}
case "min": {
return Math.min(...rows.map((row) => parseFloat(row[field]) || 0));
}
case "max": {
return Math.max(...rows.map((row) => parseFloat(row[field]) || 0));
}
default:
return 0;
}
};
// 색상 스타일 매핑
const colorMap = {
indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" },
green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" },
blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" },
purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" },
orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" },
gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" },
};
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
const [metrics, setMetrics] = useState<any[]>([]);
2025-10-24 12:14:56 +09:00
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
2025-10-24 12:14:56 +09:00
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
2025-10-27 13:24:25 +09:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element]);
const loadData = async () => {
try {
setLoading(true);
setError(null);
2025-10-24 12:14:56 +09:00
// 그룹별 카드 데이터 로드
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
await loadGroupByData();
}
// 일반 지표 데이터 로드
if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) {
await loadMetricsData();
}
} catch (err) {
console.error("데이터 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
} finally {
setLoading(false);
}
};
// 그룹별 카드 데이터 로드
const loadGroupByData = async () => {
const groupByDS = element?.customMetricConfig?.groupByDataSource;
if (!groupByDS) return;
const dataSourceType = groupByDS.type;
// Database 타입
if (dataSourceType === "database") {
if (!groupByDS.query) return;
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
2025-10-24 12:14:56 +09:00
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: groupByDS.query,
connectionType: groupByDS.connectionType || "current",
2025-10-27 13:24:25 +09:00
connectionId: (groupByDS as any).connectionId,
2025-10-24 12:14:56 +09:00
}),
});
if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
if (rows.length > 0) {
const columns = result.data.columns || Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
2025-10-27 13:24:25 +09:00
const cards = rows.map((row: any) => ({
2025-10-24 12:14:56 +09:00
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
// API 타입
else if (dataSourceType === "api") {
if (!groupByDS.endpoint) return;
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
2025-10-24 12:14:56 +09:00
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
2025-10-27 13:24:25 +09:00
method: (groupByDS as any).method || "GET",
2025-10-24 12:14:56 +09:00
url: groupByDS.endpoint,
2025-10-27 13:24:25 +09:00
headers: (groupByDS as any).headers || {},
body: (groupByDS as any).body,
authType: (groupByDS as any).authType,
authConfig: (groupByDS as any).authConfig,
2025-10-24 12:14:56 +09:00
}),
});
if (!response.ok) throw new Error("그룹별 카드 API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
let rows: any[] = [];
if (Array.isArray(result.data)) {
rows = result.data;
} else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
} else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
} else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
} else {
rows = [result.data];
}
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
2025-10-27 13:24:25 +09:00
const cards = rows.map((row: any) => ({
2025-10-24 12:14:56 +09:00
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
};
2025-10-23 14:36:14 +09:00
2025-10-24 12:14:56 +09:00
// 일반 지표 데이터 로드
const loadMetricsData = async () => {
const dataSourceType = element?.dataSource?.type;
// Database 타입
if (dataSourceType === "database") {
if (!element?.dataSource?.query) {
setMetrics([]);
return;
}
2025-10-24 12:14:56 +09:00
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
2025-10-24 12:14:56 +09:00
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
2025-10-27 13:24:25 +09:00
connectionId: (element.dataSource as any).connectionId,
2025-10-24 12:14:56 +09:00
}),
});
2025-10-23 14:36:14 +09:00
2025-10-24 12:14:56 +09:00
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
2025-10-27 13:24:25 +09:00
const calculatedMetrics =
element.customMetricConfig?.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
}) || [];
2025-10-24 12:14:56 +09:00
setMetrics(calculatedMetrics);
} else {
throw new Error(result.message || "데이터 로드 실패");
}
}
// API 타입
else if (dataSourceType === "api") {
if (!element?.dataSource?.endpoint) {
setMetrics([]);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
2025-10-24 12:14:56 +09:00
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
2025-10-27 13:24:25 +09:00
method: (element.dataSource as any).method || "GET",
2025-10-24 12:14:56 +09:00
url: element.dataSource.endpoint,
2025-10-27 13:24:25 +09:00
headers: (element.dataSource as any).headers || {},
body: (element.dataSource as any).body,
authType: (element.dataSource as any).authType,
authConfig: (element.dataSource as any).authConfig,
2025-10-24 12:14:56 +09:00
}),
});
2025-10-23 14:36:14 +09:00
2025-10-24 12:14:56 +09:00
if (!response.ok) throw new Error("API 호출 실패");
2025-10-23 14:36:14 +09:00
2025-10-24 12:14:56 +09:00
const result = await response.json();
2025-10-23 14:36:14 +09:00
2025-10-24 12:14:56 +09:00
if (result.success && result.data) {
// API 응답 데이터 구조 확인 및 처리
let rows: any[] = [];
2025-10-23 14:36:14 +09:00
2025-10-24 12:14:56 +09:00
// result.data가 배열인 경우
if (Array.isArray(result.data)) {
rows = result.data;
2025-10-23 14:36:14 +09:00
}
2025-10-24 12:14:56 +09:00
// result.data.results가 배열인 경우 (일반적인 API 응답 구조)
else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
}
// result.data.items가 배열인 경우
else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
}
// result.data.data가 배열인 경우
else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
}
// 그 외의 경우 단일 객체를 배열로 래핑
else {
rows = [result.data];
2025-10-23 14:36:14 +09:00
}
2025-10-27 13:24:25 +09:00
const calculatedMetrics =
element.customMetricConfig?.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
}) || [];
2025-10-23 14:36:14 +09:00
2025-10-24 12:14:56 +09:00
setMetrics(calculatedMetrics);
} else {
2025-10-24 12:14:56 +09:00
throw new Error("API 응답 형식 오류");
}
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center bg-white">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center bg-white p-4">
<div className="text-center">
<p className="text-sm text-red-600"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
>
</button>
</div>
</div>
);
}
2025-10-24 12:14:56 +09:00
// 데이터 소스 체크
const hasMetricsDataSource =
2025-10-23 14:36:14 +09:00
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
2025-10-24 12:14:56 +09:00
const hasGroupByDataSource =
isGroupByMode &&
element?.customMetricConfig?.groupByDataSource &&
((element.customMetricConfig.groupByDataSource.type === "database" &&
element.customMetricConfig.groupByDataSource.query) ||
(element.customMetricConfig.groupByDataSource.type === "api" &&
element.customMetricConfig.groupByDataSource.endpoint));
const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0;
// 둘 다 없으면 빈 화면 표시
const shouldShowEmpty =
(!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource);
if (shouldShowEmpty) {
return (
<div className="flex h-full items-center justify-center bg-white p-4">
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-sm font-bold text-gray-900"> </h3>
<div className="space-y-1.5 text-xs text-gray-600">
<p className="font-medium">📊 </p>
<ul className="space-y-0.5 text-left">
<li> SQL </li>
<li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li>
<li> </li>
<li>
<strong> </strong>
</li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
<p className="font-medium"> </p>
2025-10-24 12:14:56 +09:00
<p className="mb-1">
{isGroupByMode
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
</p>
{isGroupByMode && <p className="text-[9px]">💡 컬럼: 카드 , 컬럼: 카드 </p>}
</div>
</div>
</div>
);
}
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-1">
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */}
<div className="grid h-full w-full gap-1" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(35px, 1fr))" }}>
{/* 그룹별 카드 (활성화 시) */}
{isGroupByMode &&
groupedCards.map((card, index) => {
// 색상 순환 (6가지 색상)
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
const colorKey = colorKeys[index % colorKeys.length];
const colors = colorMap[colorKey];
return (
<div
key={`group-${index}`}
className={`flex flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
>
<div className="text-[8px] leading-tight text-gray-600">{card.label}</div>
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>
{card.value.toLocaleString()}
</div>
</div>
);
})}
{/* 일반 지표 카드 (항상 표시) */}
{metrics.map((metric) => {
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
return (
<div
key={metric.id}
className={`flex flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
>
<div className="text-[8px] leading-tight text-gray-600">{metric.label}</div>
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>
{formattedValue}
<span className="ml-0 text-[8px]">{metric.unit}</span>
</div>
</div>
);
})}
</div>
</div>
);
}