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

328 lines
11 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.

/**
* 운송 통계 위젯
* - 총 운송량 (톤)
* - 누적 거리 (km)
* - 정시 도착률 (%)
* - 쿼리 결과 기반 통계 계산
*/
"use client";
import { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { getApiUrl } from "@/lib/utils/apiUrl";
interface TransportStatsWidgetProps {
element?: DashboardElement;
refreshInterval?: number;
}
interface StatItem {
label: string;
value: number;
unit: string;
color: string;
icon?: string;
}
interface StatsData {
total_count: number;
total_weight: number;
total_distance: number;
on_time_rate: number;
avg_delivery_time: number;
[key: string]: number; // 동적 속성 추가
}
export default function TransportStatsWidget({ element, refreshInterval = 60000 }: TransportStatsWidgetProps) {
const [stats, setStats] = useState<StatItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 데이터 로드
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(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",
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({ total_count: 0, total_weight: 0, total_distance: 0, on_time_rate: 0, avg_delivery_time: 0 });
return;
}
// 자동으로 숫자 컬럼 감지 및 합계 계산
const firstRow = data[0];
const numericColumns: { [key: string]: number } = {};
// 모든 컬럼을 순회하며 숫자 컬럼 찾기
Object.keys(firstRow).forEach((key) => {
const value = firstRow[key];
// 숫자로 변환 가능한 컬럼만 선택
if (value !== null && !isNaN(parseFloat(value))) {
numericColumns[key] = data.reduce((sum: number, item: any) => {
return sum + (parseFloat(item[key]) || 0);
}, 0);
}
});
// 특정 키워드를 포함한 컬럼 자동 매핑
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
const deliveryTimeKeys = [
"delivery_duration",
"delivery_time",
"duration",
"배송시간",
"소요시간",
"배송소요시간",
];
// 총 운송량 찾기
let total_weight = 0;
for (const key of Object.keys(numericColumns)) {
if (weightKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
total_weight = numericColumns[key];
break;
}
}
// 누적 거리 찾기
let total_distance = 0;
for (const key of Object.keys(numericColumns)) {
if (distanceKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
total_distance = numericColumns[key];
break;
}
}
// 정시 도착률 계산
let on_time_rate = 0;
for (const key of Object.keys(firstRow)) {
if (onTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
const onTimeItems = data.filter((item: any) => {
const onTime = item[key];
return onTime !== null && onTime !== undefined;
});
if (onTimeItems.length > 0) {
const onTimeCount = onTimeItems.filter((item: any) => {
const onTime = item[key];
return onTime === true || onTime === "true" || onTime === 1 || onTime === "1";
}).length;
on_time_rate = (onTimeCount / onTimeItems.length) * 100;
}
break;
}
}
// 평균 배송시간 계산
let avg_delivery_time = 0;
// 1. 먼저 배송시간 컬럼이 있는지 확인
let foundTimeColumn = false;
for (const key of Object.keys(numericColumns)) {
if (deliveryTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
const validItems = data.filter((item: any) => {
const time = parseFloat(item[key]);
return !isNaN(time) && time > 0;
});
if (validItems.length > 0) {
const totalTime = validItems.reduce((sum: number, item: any) => {
return sum + parseFloat(item[key]);
}, 0);
avg_delivery_time = totalTime / validItems.length;
foundTimeColumn = true;
}
break;
}
}
// 2. 배송시간 컬럼이 없으면 날짜 컬럼에서 자동 계산
if (!foundTimeColumn) {
const startTimeKeys = ["created_at", "start_time", "departure_time", "출발시간", "시작시간"];
const endTimeKeys = [
"actual_delivery",
"end_time",
"arrival_time",
"도착시간",
"완료시간",
"estimated_delivery",
];
let startKey = null;
let endKey = null;
// 시작 시간 컬럼 찾기
for (const key of Object.keys(firstRow)) {
if (startTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
startKey = key;
break;
}
}
// 종료 시간 컬럼 찾기
for (const key of Object.keys(firstRow)) {
if (endTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
endKey = key;
break;
}
}
// 두 컬럼이 모두 있으면 시간 차이 계산
if (startKey && endKey) {
const validItems = data.filter((item: any) => {
return item[startKey] && item[endKey];
});
if (validItems.length > 0) {
const totalMinutes = validItems.reduce((sum: number, item: any) => {
const start = new Date(item[startKey]).getTime();
const end = new Date(item[endKey]).getTime();
const diffMinutes = (end - start) / (1000 * 60); // 밀리초 -> 분
return sum + (diffMinutes > 0 ? diffMinutes : 0);
}, 0);
avg_delivery_time = totalMinutes / validItems.length;
}
}
}
const calculatedStats: StatsData = {
total_count: data.length, // 총 건수
total_weight,
total_distance,
on_time_rate,
avg_delivery_time,
};
setStats(calculatedStats);
} 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]);
if (isLoading && !stats) {
return (
<div className="flex h-full items-center justify-center bg-muted">
<div className="text-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<div className="mt-2 text-sm text-foreground"> ...</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="flex h-full items-center justify-center bg-muted p-4">
<div className="text-center">
<div className="mb-2 text-4xl"></div>
<div className="text-sm font-medium text-foreground">{error || "데이터 없음"}</div>
{!element?.dataSource?.query && <div className="mt-2 text-xs text-muted-foreground"> </div>}
<button
onClick={loadData}
className="mt-3 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
</button>
</div>
</div>
);
}
return (
<div className="flex h-full items-center justify-center bg-background p-6">
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
{/* 총 건수 */}
<div className="rounded-lg border bg-primary/10 p-4 text-center">
<div className="text-sm text-foreground"> </div>
<div className="mt-2 text-3xl font-bold text-primary">
{stats.total_count.toLocaleString()}
<span className="ml-1 text-lg"></span>
</div>
</div>
{/* 총 운송량 */}
<div className="rounded-lg border bg-success/10 p-4 text-center">
<div className="text-sm text-foreground"> </div>
<div className="mt-2 text-3xl font-bold text-success">
{stats.total_weight.toFixed(1)}
<span className="ml-1 text-lg"></span>
</div>
</div>
{/* 누적 거리 */}
<div className="rounded-lg border bg-primary/10 p-4 text-center">
<div className="text-sm text-foreground"> </div>
<div className="mt-2 text-3xl font-bold text-primary">
{stats.total_distance.toFixed(1)}
<span className="ml-1 text-lg">km</span>
</div>
</div>
{/* 정시 도착률 */}
<div className="rounded-lg border bg-primary/10 p-4 text-center">
<div className="text-sm text-foreground"> </div>
<div className="mt-2 text-3xl font-bold text-primary">
{stats.on_time_rate.toFixed(1)}
<span className="ml-1 text-lg">%</span>
</div>
</div>
{/* 평균 배송시간 */}
<div className="rounded-lg border bg-warning/10 p-4 text-center">
<div className="text-sm text-foreground"> </div>
<div className="mt-2 text-3xl font-bold text-warning">
{stats.avg_delivery_time.toFixed(1)}
<span className="ml-1 text-lg"></span>
</div>
</div>
</div>
</div>
);
}