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

294 lines
9.8 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.

"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { getApiUrl } from "@/lib/utils/apiUrl";
interface CustomMetricTestWidgetProps {
element: DashboardElement;
}
// 필터 적용 함수
const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => {
if (!filters || filters.length === 0) return rows;
return rows.filter((row) => {
return filters.every((filter) => {
const cellValue = String(row[filter.column] || "");
const filterValue = filter.value;
switch (filter.operator) {
case "=":
return cellValue === filterValue;
case "!=":
return cellValue !== filterValue;
case ">":
return parseFloat(cellValue) > parseFloat(filterValue);
case "<":
return parseFloat(cellValue) < parseFloat(filterValue);
case ">=":
return parseFloat(cellValue) >= parseFloat(filterValue);
case "<=":
return parseFloat(cellValue) <= parseFloat(filterValue);
case "contains":
return cellValue.includes(filterValue);
case "not_contains":
return !cellValue.includes(filterValue);
default:
return true;
}
});
});
};
// 집계 함수 실행
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;
}
};
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
const [value, setValue] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const config = element?.customMetricConfig;
console.log("📊 [CustomMetricTestWidget] 렌더링:", {
element,
config,
dataSource: element?.dataSource,
});
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element]);
const loadData = async () => {
try {
setLoading(true);
setError(null);
const dataSourceType = element?.dataSource?.type;
// Database 타입
if (dataSourceType === "database") {
if (!element?.dataSource?.query) {
setValue(0);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/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",
connectionId: (element.dataSource as any).connectionId,
}),
});
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
let rows = result.data.rows;
// 필터 적용
if (config?.filters && config.filters.length > 0) {
rows = applyFilters(rows, config.filters);
}
// 집계 계산
if (config?.valueColumn && config?.aggregation) {
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
setValue(calculatedValue);
} else {
setValue(0);
}
} else {
throw new Error(result.message || "데이터 로드 실패");
}
}
// API 타입
else if (dataSourceType === "api") {
if (!element?.dataSource?.endpoint) {
setValue(0);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: (element.dataSource as any).method || "GET",
url: element.dataSource.endpoint,
headers: (element.dataSource as any).headers || {},
body: (element.dataSource as any).body,
authType: (element.dataSource as any).authType,
authConfig: (element.dataSource as any).authConfig,
}),
});
if (!response.ok) throw new Error("API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
let rows: any[] = [];
// API 응답 데이터 구조 확인 및 처리
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 (config?.filters && config.filters.length > 0) {
rows = applyFilters(rows, config.filters);
}
// 집계 계산
if (config?.valueColumn && config?.aggregation) {
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
setValue(calculatedValue);
} else {
setValue(0);
}
} else {
throw new Error("API 응답 형식 오류");
}
}
} catch (err) {
console.error("데이터 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center bg-background">
<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-muted-foreground"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center bg-background p-4">
<div className="text-center">
<p className="text-sm text-destructive"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
>
</button>
</div>
</div>
);
}
// 설정 체크
const hasDataSource =
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
const hasConfig = config?.valueColumn && config?.aggregation;
// 설정이 없으면 안내 화면
if (!hasDataSource || !hasConfig) {
return (
<div className="flex h-full items-center justify-center bg-background p-4">
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-sm font-bold text-foreground"> </h3>
<div className="space-y-1.5 text-xs text-foreground">
<p className="font-medium">📊 </p>
<ul className="space-y-0.5 text-left">
<li> </li>
<li> </li>
<li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
<p className="font-medium"> </p>
<p>1. </p>
<p>2. ()</p>
<p>3. </p>
<p>4. </p>
</div>
</div>
</div>
);
}
// 소수점 자릿수 (기본: 0)
const decimals = config?.decimals ?? 0;
const formattedValue = value.toFixed(decimals);
// 통계 카드 렌더링 (전체 크기 꽉 차게)
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
{/* 제목 */}
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
{/* 값 */}
<div className="flex items-baseline gap-1">
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
</div>
{/* 필터 표시 (디버깅용, 작게) */}
{config?.filters && config.filters.length > 0 && (
<div className="text-muted-foreground mt-2 text-xs">: {config.filters.length} </div>
)}
</div>
);
}